Compare commits

...

44 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
Pouzor 12d527aad6 feat: manage canvases with custom name and icon
Make designs (canvases) fully user-manageable: create with a chosen name
and icon, rename, change icon, and delete. Replaces the hardcoded
"New Electrical Design" button with a generic "New Canvas" flow.

- Add Design.icon column + migration that backfills legacy rows
  (electrical -> zap, others -> dashboard)
- DesignModal: name input + curated lucide icon picker (create + edit)
- Sidebar switcher gains per-canvas edit/delete; delete guards the last
  canvas and confirms
- designStore: addDesign/updateDesign/removeDesign with active reassignment
- Fix data loss on design switch: abort load when the save fails and keep
  unsaved edits; skip the save-old step when the previous canvas was deleted
- designsApi create/update carry icon; design_type kept for back-compat

Tests: backend design CRUD (icon + cascade + last-canvas guard), designStore
actions, designIcons resolver, DesignModal create/edit/validation.

ha-relevant: yes
2026-06-02 15:47:57 +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
Pranjal Joshi eb7b0c6d38 test: fix canvasApi.load test to expect params arg 2026-05-31 15:07:29 +05:30
Pranjal Joshi be320c2ac4 fix: auto-create default design in save_canvas when none exists 2026-05-31 15:03:54 +05:30
Pranjal Joshi 60ce648a96 ci: fix remaining lint/type errors - react-compiler deps, None-indexable 2026-05-31 14:56:55 +05:30
Pranjal Joshi d5e35a885d ci: fix lint/type errors - missing electrical icons, unused vars, mypy issues 2026-05-31 14:53:38 +05:30
Pranjal Joshi 3a57d809a4 fix: compatibility with multi-design schema for scan/zigbee/liveview/CustomStyleModal
- liveview.py: replace hardcoded CanvasState PK lookup (get(1)) with
  design_id-aware query; filter nodes/edges by design_id
- scan.py: add design_id to bulk approve, single approve, and edge
  resolution Node/Edge constructors (fallback to first design)
- zigbee.py: add design_id to coordinator auto-approval Node constructor
- schemas/nodes.py: add design_id to NodeCreate and NodeResponse
- schemas/edges.py: add design_id to EdgeCreate
- CustomStyleModal.tsx: add 'electrical' to EDITABLE_EDGE_TYPES
2026-05-31 14:44:17 +05:30
Pranjal Joshi 46435605eb feat: multi-design canvas system with electrical nodes/edges
Backend: New Design model + designs table; design_id FK on nodes, edges, canvas_state; migration seeds default 'Network Topology' design; full CRUD API for designs; canvas load/save accept design_id.

Frontend: designStore (Zustand), design switcher in Sidebar, design-aware canvas load/save, auto-save on design switch.

Electrical node types (14): grid, ups, battery, generator, solar_panel, inverter, circuit_breaker, contactor, electrical_switch, socket, light, meter, transformer, load — icons, registrations, accent colors in all 6 themes.

Electrical edge type: registered in edgeTypes, BASE_STYLES, edgeColors, all theme edgeColors, EDGE_DEFAULT_COLORS.

Bug fixes: data corruption on design switch (stale closure), race condition on save-then-load, missing Zap import, missing Electrical group in NodeModal, missing electrical entries in custom theme edgeColors, inline imports hoisted.
2026-05-31 14:35:22 +05:30
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
Rémy 4ba04660c8 Merge pull request #175 from Pouzor/fix/zeroconf-cve-bump
fix(deps): bump zeroconf to 0.149.7 for CVE fixes
2026-05-31 01:33:12 +02:00
Pouzor 4b06ce6ef6 fix(deps): bump zeroconf 0.131.0 -> 0.149.7 for CVE fixes
Resolves CVE-2026-47180/47183/47184 flagged by Security workflow
pip-audit on main. Scanner mDNS API (AsyncServiceBrowser, async_request)
unchanged; scan tests pass.

ha-relevant: no
2026-05-31 01:26:42 +02:00
Rémy 6761f73c17 Merge pull request #172 from Pouzor/feat/switch-ports-64-fibre-edge
feat(canvas): switch port cap 64 + port numbers (#20) and fibre edge type (#21)
2026-05-31 01:09:36 +02:00
Pouzor 1431f5b19e feat(canvas): raise switch port cap to 64, add port numbers and fibre edge type
Issue #20: bump MAX_BOTTOM_HANDLES 48 -> 64 (covers 48+4 SFP switches) and add
a per-node "Show Port Numbers" toggle that labels each bottom connection point.

Issue #21: add `fibre` as a first-class edge/connection type (bright cyan with a
subtle glow) alongside ethernet/wifi/iot/vlan/virtual/cluster - selectable in the
edge modal, themeable, registered in the React Flow edgeTypes registry, and
round-tripped through YAML import/export.

Backport of homelable-hacs PR #23.

ha-relevant: yes
2026-05-29 17:56:51 +02:00
76 changed files with 3202 additions and 529 deletions
+1 -1
View File
@@ -1 +1 @@
2.2.0 2.4.0
+42 -17
View File
@@ -1,13 +1,14 @@
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
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.api.deps import get_current_user
from app.db.database import get_db from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -16,10 +17,20 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse: async def load_canvas(
nodes = (await db.execute(select(Node))).scalars().all() design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
edges = (await db.execute(select(Edge))).scalars().all() db: AsyncSession = Depends(get_db),
state = await db.get(CanvasState, 1) _: str = Depends(get_current_user),
) -> CanvasStateResponse:
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse( return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes], nodes=[NodeResponse.model_validate(n) for n in nodes],
@@ -32,18 +43,28 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
@router.post("/save") @router.post("/save")
async def save_canvas( async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]: ) -> dict[str, bool | str]:
design_id = body.design_id
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
db.add(new_design)
await db.flush()
design_id = new_design.id
incoming_node_ids = {n.id for n in body.nodes} incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges} incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas # Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node))).scalars().all() existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for node in existing_nodes: for node in existing_nodes:
if node.id not in incoming_node_ids: if node.id not in incoming_node_ids:
await db.delete(node) await db.delete(node)
# Delete edges removed from canvas # Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge))).scalars().all() existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for edge in existing_edges: for edge in existing_edges:
if edge.id not in incoming_edge_ids: if edge.id not in incoming_edge_ids:
await db.delete(edge) await db.delete(edge)
@@ -53,29 +74,33 @@ async def save_canvas(
# Upsert nodes # Upsert nodes
for node_data in body.nodes: for node_data in body.nodes:
db_node = await db.get(Node, node_data.id) db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node: if db_node:
for field, value in node_data.model_dump().items(): for field, value in payload.items():
setattr(db_node, field, value) setattr(db_node, field, value)
else: else:
db.add(Node(**node_data.model_dump())) db.add(Node(**payload))
# Upsert edges # Upsert edges
for edge_data in body.edges: for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id) db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge: if db_edge:
for field, value in edge_data.model_dump().items(): for field, value in payload.items():
setattr(db_edge, field, value) setattr(db_edge, field, value)
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**payload))
# Upsert viewport + custom style # Upsert viewport + custom style
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, design_id)
if state: if state:
state.viewport = body.viewport state.viewport = body.viewport
state.custom_style = body.custom_style state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc) state.saved_at = datetime.now(timezone.utc)
else: else:
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style)) db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
await db.commit() await db.commit()
return {"saved": True} return {"saved": True}
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
@router.get("", response_model=list[DesignResponse])
async def list_designs(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[DesignResponse]:
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
return [DesignResponse.model_validate(d) for d in designs]
@router.post("", response_model=DesignResponse, status_code=201)
async def create_design(
body: DesignCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
db.add(design)
await db.flush()
# Create empty canvas state for the new design
db.add(CanvasState(design_id=design.id))
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.put("/{design_id}", response_model=DesignResponse)
async def update_design(
design_id: str,
body: DesignUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
if body.name is not None:
design.name = body.name
if body.icon is not None:
design.icon = body.icon
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.delete("/{design_id}", status_code=204)
async def delete_design(
design_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> None:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
# Count remaining designs — prevent deleting the last one
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for n in nodes:
await db.delete(n)
await db.delete(design)
await db.commit()
+33 -4
View File
@@ -2,12 +2,14 @@ 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, Edge, Node from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasStateResponse from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -15,9 +17,30 @@ 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),
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse: ) -> CanvasStateResponse:
"""Read-only public canvas endpoint. """Read-only public canvas endpoint.
@@ -30,9 +53,15 @@ async def liveview_canvas(
if not key or not hmac.compare_digest(key, settings.liveview_key): if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key") raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all() if design_id is None:
edges = (await db.execute(select(Edge))).scalars().all() first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
state = await db.get(CanvasState, 1) design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
custom_style: dict[str, Any] | None = state.custom_style if state else None custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse( return CanvasStateResponse(
+54 -3
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user 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 AsyncSessionLocal, get_db from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.nodes import NodeCreate from app.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan from app.services.scanner import request_cancel, run_scan
@@ -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]
@@ -118,6 +146,10 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Determine target design (use first design as fallback)
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
result = await db.execute( result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids), PendingDevice.id.in_(payload.device_ids),
@@ -134,16 +166,18 @@ 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),
design_id=default_design_id,
) )
db.add(node) db.add(node)
created_nodes.append(node) created_nodes.append(node)
@@ -227,6 +261,12 @@ async def approve_device(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> dict[str, Any]:
# Determine target design
node_design_id = node_data.design_id
if node_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
node_design_id = first.id if first else None
device = await db.get(PendingDevice, device_id) device = await db.get(PendingDevice, device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
@@ -234,19 +274,24 @@ 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,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
@@ -328,12 +373,18 @@ async def _resolve_pending_links_for_ieee(
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs: if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link) await db.delete(link)
continue continue
# Use the source node's design_id for the edge
edge_design_id = self_node.design_id if self_node else None
if edge_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
edge_design_id = first.id if first else None
edge = Edge( edge = Edge(
source=src_id, source=src_id,
target=tgt_id, target=tgt_id,
type="iot", type="iot",
source_handle="bottom", source_handle="bottom",
target_handle="top-t", target_handle="top-t",
design_id=edge_design_id,
) )
db.add(edge) db.add(edge)
await db.flush() await db.flush()
+13 -2
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import AsyncSessionLocal, get_db from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import ( from app.schemas.zigbee import (
ZigbeeCoordinatorOut, ZigbeeCoordinatorOut,
@@ -138,6 +138,10 @@ async def _persist_pending_import(
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE. Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
All zigbee-source links are wiped and re-inserted from the new map. All zigbee-source links are wiped and re-inserted from the new map.
""" """
# Determine target design (use first design as fallback)
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
coordinator_out: ZigbeeCoordinatorOut | None = None coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False coordinator_existed = False
pending_created = 0 pending_created = 0
@@ -174,6 +178,7 @@ async def _persist_pending_import(
ieee_address=ieee, ieee_address=ieee,
services=[], services=[],
properties=props, properties=props,
design_id=default_design_id,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
@@ -223,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
+101 -5
View File
@@ -1,5 +1,7 @@
import json as _json
import logging import logging
import shutil import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -89,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):
@@ -168,20 +172,113 @@ async def init_db() -> None:
except OperationalError as exc: except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc) logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations ------------------------------------- # --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
await _try_migrate(
conn,
"CREATE TABLE IF NOT EXISTS designs ("
"id VARCHAR PRIMARY KEY,"
"name VARCHAR NOT NULL,"
"design_type VARCHAR NOT NULL DEFAULT 'network',"
"created_at DATETIME,"
"updated_at DATETIME"
")",
label="designs.table",
)
# Add user-chosen icon to designs (idempotent), then backfill existing rows
# so legacy designs keep a sensible icon based on their original type.
await _try_migrate(
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
)
# Seed default Network Topology design if designs table is empty
_default_design_id = str(_uuid_mod.uuid4())
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
count_row = row.fetchone()
count = count_row[0] if count_row else 0
if count == 0:
await conn.exec_driver_sql(
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
(_default_design_id,),
)
else:
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
default = row2.fetchone()
_default_design_id = default[0] if default else _default_design_id
# Add design_id to nodes
await _try_migrate(
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="nodes.design_id",
)
# Assign existing nodes to default design
await conn.exec_driver_sql(
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Add design_id to edges
await _try_migrate(
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="edges.design_id",
)
# Assign existing edges to default design
await conn.exec_driver_sql(
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
try:
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
cols = info.fetchall()
has_design_id = any(c[1] == "design_id" for c in cols)
if not has_design_id:
logger.info("Migrating canvas_state: switching to design_id primary key")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE canvas_state_new ("
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
"viewport JSON,"
"custom_style JSON,"
"saved_at DATETIME"
")"
)
# Copy existing row(s), mapping id=1 to default design_id
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
for old in old_rows.fetchall():
cs_id, viewport, custom_style, saved_at = old
target_design = _default_design_id
await conn.exec_driver_sql(
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
"VALUES (?, ?, ?, ?)",
(target_design, viewport, custom_style, saved_at),
)
await conn.exec_driver_sql("DROP TABLE canvas_state")
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError): with suppress(OperationalError):
rows = await conn.exec_driver_sql( rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL" "FROM nodes WHERE properties IS NULL"
) )
for row in rows.fetchall(): for r in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
props = [] props = []
visible = bool(show_hardware) visible = bool(show_hardware)
if cpu_model: if cpu_model:
@@ -192,7 +289,6 @@ async def init_db() -> None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None: if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql( await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?", "UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id), (_json.dumps(props), node_id),
+15 -1
View File
@@ -16,12 +16,24 @@ def _uuid() -> str:
return str(uuid.uuid4()) return str(uuid.uuid4())
class Design(Base):
__tablename__ = "designs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String, nullable=False)
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class Node(Base): class Node(Base):
__tablename__ = "nodes" __tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False) type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False) label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
hostname: Mapped[str | None] = mapped_column(String) hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String) ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String) mac: Mapped[str | None] = mapped_column(String)
@@ -42,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)
@@ -61,6 +74,7 @@ class Edge(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
type: Mapped[str] = mapped_column(String, default="ethernet") type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String) label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer) vlan_id: Mapped[int | None] = mapped_column(Integer)
@@ -77,7 +91,7 @@ class Edge(Base):
class CanvasState(Base): class CanvasState(Base):
__tablename__ = "canvas_state" __tablename__ = "canvas_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
from app.api.routes import settings as settings_routes from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
@@ -51,6 +51,7 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"]) app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"]) app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"]) app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"]) app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"]) app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+2
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
@@ -63,6 +64,7 @@ class CanvasSaveRequest(BaseModel):
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict[str, Any] = {} viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None custom_style: dict[str, Any] | None = None
design_id: str | None = None
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from pydantic import BaseModel
class DesignCreate(BaseModel):
name: str
icon: str = "dashboard"
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
# the chosen icon now drives presentation. Defaults to a generic canvas.
design_type: str = "network"
class DesignUpdate(BaseModel):
name: str | None = None
icon: str | None = None
class DesignResponse(BaseModel):
id: str
name: str
design_type: str
icon: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+1 -1
View File
@@ -26,7 +26,7 @@ class EdgeBase(BaseModel):
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
pass design_id: str | None = None
class EdgeUpdate(BaseModel): class EdgeUpdate(BaseModel):
+5 -1
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
@@ -34,7 +35,7 @@ class NodeBase(BaseModel):
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
pass design_id: str | None = None
class NodeUpdate(BaseModel): class NodeUpdate(BaseModel):
@@ -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
@@ -68,6 +70,8 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
id: str id: str
design_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None last_seen: datetime | None = None
response_time_ms: int | None = None response_time_ms: int | None = None
created_at: datetime created_at: datetime
+1 -1
View File
@@ -15,7 +15,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.131.0 zeroconf==0.149.7
aiomqtt==2.3.0 aiomqtt==2.3.0
# Dev # Dev
+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)
+165
View File
@@ -0,0 +1,165 @@
import uuid
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
def edge_payload(src, tgt, **kwargs):
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
# ── auth ──────────────────────────────────────────────────────────────────────
async def test_list_designs_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/designs")
assert res.status_code == 401
async def test_create_design_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/designs", json={"name": "X"})
assert res.status_code == 401
# ── list / create ─────────────────────────────────────────────────────────────
async def test_list_designs_empty(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/designs", headers=headers)
assert res.status_code == 200
assert res.json() == []
async def test_create_design_defaults(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Workshop")
assert design["name"] == "Workshop"
assert design["design_type"] == "network"
assert design["icon"] == "dashboard"
assert "id" in design and design["id"]
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Net", design_type="network")
assert design["design_type"] == "network"
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Power", icon="zap")
assert design["icon"] == "zap"
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="D", icon="dashboard")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
assert res.status_code == 200
assert res.json()["icon"] == "server"
# Name left untouched when only icon is sent.
assert res.json()["name"] == "D"
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old", icon="dashboard")
res = await client.put(
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["name"] == "New"
assert body["icon"] == "network"
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Has Canvas")
# Loading the new design returns an (empty) canvas without falling back to another design.
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
assert res.status_code == 200
body = res.json()
assert body["nodes"] == []
assert body["edges"] == []
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
a = await _create(client, headers, name="First")
b = await _create(client, headers, name="Second")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
ids = [d["id"] for d in listed]
assert ids == [a["id"], b["id"]]
# ── update ────────────────────────────────────────────────────────────────────
async def test_update_design_renames(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old Name")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
assert res.status_code == 200
assert res.json()["name"] == "New Name"
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
assert res.status_code == 404
# ── delete ────────────────────────────────────────────────────────────────────
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Only One")
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
assert res.status_code == 400
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
await _create(client, headers, name="Keep")
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
assert res.status_code == 404
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
keep = await _create(client, headers, name="Keep")
victim = await _create(client, headers, name="Victim")
# Populate the victim design with nodes + an edge via canvas save.
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
headers=headers,
)
assert save.status_code == 200
# Populate the kept design too, to prove scoping.
k1 = node_payload(label="K")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
headers=headers,
)
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
assert res.status_code == 204
# Victim gone from list.
listed = (await client.get("/api/v1/designs", headers=headers)).json()
assert [d["id"] for d in listed] == [keep["id"]]
# Kept design's node survives untouched.
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
assert len(kept_canvas["nodes"]) == 1
assert kept_canvas["nodes"][0]["label"] == "K"
+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",
+134 -35
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'
@@ -28,9 +29,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
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 } 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'
@@ -40,10 +42,11 @@ 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()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling() useStatusPolling()
@@ -67,32 +70,79 @@ 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)
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope.
const handleSave = useCallback(async () => { // Returns true on success, false on failure — the design-switch effect relies
// on this to avoid loading (and clobbering) the canvas when a save fails.
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
try { try {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return return true
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle }) await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return true
} catch { } catch {
toast.error('Save failed') toast.error('Save failed')
return false
} }
}, [nodes, edges, markSaved, activeTheme, customStyle]) }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
const loadCanvasFromApi = useCallback(async (designId?: string) => {
try {
const res = await canvasApi.load(designId)
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
const proxmoxContainerMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(rfNodes, rfEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
} catch {
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle])
const loadDesignsAndCanvas = useCallback(async () => {
if (STANDALONE) return
try {
const res = await designsApi.list()
const loadedDesigns = res.data
setDesigns(loadedDesigns)
const targetId = activeDesignId ?? loadedDesigns[0]?.id
if (targetId) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
}
} catch {
// If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges)
}
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
// Load canvas on auth (or immediately in standalone mode) // Load canvas on auth (or immediately in standalone mode)
useEffect(() => { useEffect(() => {
if (STANDALONE) { if (STANDALONE) {
@@ -112,38 +162,59 @@ export default function App() {
return return
} }
if (!isAuthenticated) return if (!isAuthenticated) return
canvasApi.load() loadDesignsAndCanvas()
.then((res) => { }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { // Reload canvas when active design changes (after initial load)
// Build a map of container mode nodes to know if children should be nested const initialLoadDone = useRef(false)
const proxmoxContainerMap = new Map<string, boolean>( const prevDesignRef = useRef<string | null>(null)
(apiNodes as ApiNode[]) // Set while we programmatically revert activeDesignId after a failed save, so
.filter((n) => n.type === 'group' || n.container_mode === true) // the re-entrant effect run skips save/load and just re-syncs the refs.
.map((n) => [n.id, true]) const revertingRef = useRef(false)
) useEffect(() => {
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) if (revertingRef.current) {
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) revertingRef.current = false
const savedTheme = res.data.viewport?.theme_id prevDesignRef.current = activeDesignId
if (savedTheme) setTheme(savedTheme) return
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) }
loadCanvas(rfNodes, rfEdges) if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
} else { const oldId = prevDesignRef.current
loadCanvas(demoNodes, demoEdges) // If the previous design was deleted (no longer in the list), don't try to
} // save into it — just load the newly-selected design.
}) const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false
.catch(() => loadCanvas(demoNodes, demoEdges)) if (oldId && oldId !== activeDesignId && oldStillExists) {
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // Save current (old) canvas data under the old design ID before switching.
// We call handleSave directly (not via ref) so it runs in this effect's
// closure where activeDesignId is already the NEW value — the override
// ensures data is stored under the correct design_id.
const targetId = activeDesignId
handleSave(oldId).then((ok) => {
if (ok) {
loadCanvasFromApi(targetId)
} else {
// Save failed: don't load the new design — that would overwrite the
// unsaved in-memory canvas. Revert the selection back to the old
// design so the UI matches the data still on screen.
toast.error('Switch cancelled — unsaved changes kept')
revertingRef.current = true
setActiveDesign(oldId)
}
})
} else {
loadCanvasFromApi(activeDesignId)
}
}
if (activeDesignId) {
prevDesignRef.current = activeDesignId
initialLoadDone.current = true
}
}, [activeDesignId])
// 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(() => {
@@ -157,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)
@@ -384,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 }
@@ -513,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}
/> />
@@ -528,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">
@@ -709,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)}
+11 -1
View File
@@ -127,7 +127,7 @@ describe('api/client', () => {
it('canvasApi.load GETs /canvas', () => { it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load() mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas') expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
}) })
it('canvasApi.save POSTs to /canvas/save with payload', () => { it('canvasApi.save POSTs to /canvas/save with payload', () => {
@@ -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')
+17 -2
View File
@@ -28,12 +28,16 @@ export const authApi = {
} }
export const canvasApi = { export const canvasApi = {
load: () => api.get('/canvas'), load: (design_id?: string) => {
const params = design_id ? { design_id } : {}
return api.get('/canvas', { params })
},
save: (payload: { save: (payload: {
nodes: object[] nodes: object[]
edges: object[] edges: object[]
viewport: object viewport: object
custom_style?: object | null custom_style?: object | null
design_id?: string | null
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
@@ -49,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 = {
@@ -89,6 +95,15 @@ export const settingsApi = {
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
} }
export const designsApi = {
list: () => api.get<import('@/types').Design[]>('/designs'),
create: (data: { name: string; icon?: string; design_type?: string }) =>
api.post<import('@/types').Design>('/designs', data),
update: (id: string, data: { name?: string; icon?: string }) =>
api.put<import('@/types').Design>(`/designs/${id}`, data),
delete: (id: string) => api.delete(`/designs/${id}`),
}
export const zigbeeApi = { export const zigbeeApi = {
testConnection: (data: { testConnection: (data: {
mqtt_host: string mqtt_host: string
+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}
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
vi.mock('@/utils/handleUtils', () => ({ vi.mock('@/utils/handleUtils', () => ({
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: () => [50], bottomHandlePositions: (count: number) => {
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
},
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
})) }))
@@ -171,6 +174,29 @@ describe('BaseNode — properties rendering', () => {
}) })
}) })
describe('BaseNode — port numbers (issue #20)', () => {
it('renders a number above each bottom handle when show_port_numbers is on', () => {
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.getByText('3')).toBeDefined()
expect(screen.getByText('4')).toBeDefined()
})
it('does not render port numbers when show_port_numbers is off', () => {
renderBaseNode({ bottom_handles: 4 })
expect(screen.queryByText('1')).toBeNull()
expect(screen.queryByText('4')).toBeNull()
})
it('numbers match the handle count', () => {
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.queryByText('3')).toBeNull()
})
})
describe('BaseNode — services visibility toggle', () => { describe('BaseNode — services visibility toggle', () => {
it('does not render service toggle button on the node', () => { it('does not render service toggle button on the node', () => {
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] }) renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
@@ -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()
})
})
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { edgeTypes } from '../edgeTypes'
import { EDGE_TYPE_LABELS, type EdgeType } from '@/types'
describe('edgeTypes registry', () => {
// Regression (issue #21): an EdgeType missing here makes React Flow fall back
// to its built-in default edge — grey, unstyled, ignoring custom_color.
it('registers a component for every EdgeType', () => {
for (const type of Object.keys(EDGE_TYPE_LABELS) as EdgeType[]) {
expect(edgeTypes[type as keyof typeof edgeTypes]).toBeDefined()
}
})
it('registers fibre', () => {
expect(edgeTypes.fibre).toBeDefined()
})
})
@@ -7,4 +7,6 @@ export const edgeTypes = {
vlan: HomelableEdge, vlan: HomelableEdge,
virtual: HomelableEdge, virtual: HomelableEdge,
cluster: HomelableEdge, cluster: HomelableEdge,
fibre: HomelableEdge,
electrical: HomelableEdge,
} }
+15 -12
View File
@@ -323,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
vlan: { strokeWidth: 2.5 }, vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' }, virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
electrical: { stroke: edgeColors.electrical, strokeWidth: 2 },
} }
const customColor = data?.custom_color as string | undefined const customColor = data?.custom_color as string | undefined
@@ -376,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
@@ -394,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>
@@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const targetId = `${sourceId}-t` const targetId = `${sourceId}-t`
return ( return (
<span key={sourceId}> <span key={sourceId}>
{data.show_port_numbers && (
<span
className="absolute font-mono leading-none pointer-events-none select-none"
style={{
left: `${leftPct}%`,
bottom: 3,
transform: 'translateX(-50%)',
fontSize: 7,
color: theme.colors.nodeSubtextColor,
}}
>
{idx + 1}
</span>
)}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
@@ -32,3 +33,19 @@ export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} /> export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} /> export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} /> export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
// Electrical node types
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
export const BatteryNode = (props: N) => <BaseNode {...props} icon={Battery} />
export const GeneratorNode = (props: N) => <BaseNode {...props} icon={Fuel} />
export const SolarPanelNode = (props: N) => <BaseNode {...props} icon={Sun} />
export const InverterNode = (props: N) => <BaseNode {...props} icon={Repeat2} />
export const CircuitBreakerNode = (props: N) => <BaseNode {...props} icon={Split} />
export const ContactorNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
export const ElectricalSwitchNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
export const SocketNode = (props: N) => <BaseNode {...props} icon={Cable} />
export const LightNode = (props: N) => <BaseNode {...props} icon={Lightbulb} />
export const MeterNode = (props: N) => <BaseNode {...props} icon={Gauge} />
export const TransformerNode = (props: N) => <BaseNode {...props} icon={Combine} />
export const LoadNode = (props: N) => <BaseNode {...props} icon={Zap} />
@@ -1,4 +1,12 @@
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index' import {
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
LightNode, MeterNode, TransformerNode, LoadNode,
} from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode' import { GroupNode } from './GroupNode'
@@ -31,4 +39,18 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode, zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode, zigbee_enddevice: ZigbeeEndDeviceNode,
grid: GridNode,
ups: UpsNode,
battery: BatteryNode,
generator: GeneratorNode,
solar_panel: SolarPanelNode,
inverter: InverterNode,
circuit_breaker: CircuitBreakerNode,
contactor: ContactorNode,
electrical_switch: ElectricalSwitchNode,
socket: SocketNode,
light: LightNode,
meter: MeterNode,
transformer: TransformerNode,
load: LoadNode,
} }
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
'generic', 'generic',
] ]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
const NODE_ICONS: Record<string, LucideIcon> = { const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
@@ -0,0 +1,87 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
export interface DesignFormData {
name: string
icon: string
}
interface DesignModalProps {
open: boolean
onClose: () => void
onSubmit: (data: DesignFormData) => void
initial?: DesignFormData
title?: string
submitLabel?: string
}
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
const handleSubmit = () => {
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, icon })
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="design-name">Name</Label>
<Input
id="design-name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
placeholder="e.g. Home Network, Rack Power"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label>Icon</Label>
<div className="grid grid-cols-8 gap-1.5">
{DESIGN_ICONS.map((entry) => {
const Icon = entry.icon
const selected = entry.key === icon
return (
<button
key={entry.key}
type="button"
aria-label={entry.label}
aria-pressed={selected}
title={entry.label}
onClick={() => setIcon(entry.key)}
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
selected
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
}`}
>
<Icon size={16} />
</button>
)
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} disabled={!name.trim()}>{submitLabel}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -19,6 +19,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, { label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
{ label: 'Generic', types: ['generic', 'groupRect'] }, { label: 'Generic', types: ['generic', 'groupRect'] },
] ]
@@ -513,6 +514,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<span>{MIN_BOTTOM_HANDLES}</span> <span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span> <span>{MAX_BOTTOM_HANDLES}</span>
</div> </div>
<div className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.show_port_numbers}
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle port numbers"
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
</div> </div>
)} )}
@@ -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>
)
}
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DesignModal } from '../DesignModal'
import { DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
function renderModal(props: Partial<Parameters<typeof DesignModal>[0]> = {}) {
const onClose = vi.fn()
const onSubmit = vi.fn()
render(<DesignModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
return { onClose, onSubmit }
}
describe('DesignModal', () => {
it('creates with the typed name and default icon', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
})
it('submits the selected icon', () => {
const { onSubmit } = renderModal()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
})
it('trims whitespace and blocks empty names', () => {
const { onSubmit } = renderModal()
// Empty → submit disabled, no call.
const submit = screen.getByRole('button', { name: 'Create' })
expect(submit).toBeDisabled()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
fireEvent.click(submit)
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
})
it('prefills name and icon in edit mode', () => {
const { onSubmit } = renderModal({
initial: { name: 'Existing', icon: 'server' },
title: 'Edit Canvas',
submitLabel: 'Save',
})
expect(screen.getByLabelText('Name')).toHaveValue('Existing')
// The server icon button is pre-selected.
expect(screen.getByRole('button', { name: 'Server' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Existing', icon: 'server' })
})
it('submits on Enter from the name field', () => {
const { onSubmit } = renderModal()
const input = screen.getByLabelText('Name')
fireEvent.change(input, { target: { value: 'Quick' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
})
it('calls onClose from Cancel', () => {
const { onClose, onSubmit } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
})
@@ -59,6 +59,13 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].label).toBeUndefined() expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
}) })
it('round-trips the fibre type through submit (issue #21)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'fibre' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].type).toBe('fibre')
})
// ── VLAN ID field ───────────────────────────────────────────────────────── // ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => { it('does not show VLAN ID field for ethernet type', () => {
@@ -416,20 +416,30 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
}) })
it('supports the full 1..48 range', () => { it('supports the full 1..64 range (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE }) const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1') expect(slider.min).toBe('1')
expect(slider.max).toBe('48') expect(slider.max).toBe('64')
fireEvent.change(slider, { target: { value: '48' } }) fireEvent.change(slider, { target: { value: '52' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' })) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
}) })
it('clamps pre-filled out-of-range values into [1,48]', () => { it('clamps pre-filled out-of-range values into [1,64]', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } }) renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48') expect(slider.value).toBe('64')
})
it('toggles show_port_numbers and submits it (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE })
const toggle = screen.getByLabelText('Toggle port numbers')
expect(toggle.getAttribute('aria-checked')).toBe('false')
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-checked')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).show_port_numbers).toBe(true)
}) })
// ── Zigbee nodes ────────────────────────────────────────────────────── // ── Zigbee nodes ──────────────────────────────────────────────────────
@@ -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()
})
})
+129 -128
View File
@@ -1,22 +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 } 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 { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client' import { designsApi, scanApi } from '@/api/client'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
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' },
@@ -41,15 +39,47 @@ 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)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
if (!designModal) return
try {
if (designModal.mode === 'create') {
const res = await designsApi.create({ name: data.name, icon: data.icon })
addDesign(res.data)
} else if (designModal.design) {
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
}
setDesignModal(null)
} catch {
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
}
}, [designModal, addDesign, updateDesign])
const handleDesignDelete = useCallback(async (d: Design) => {
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
try {
await designsApi.delete(d.id)
removeDesign(d.id)
toast.success('Canvas deleted')
} catch {
toast.error('Failed to delete canvas')
}
}, [designs.length, removeDesign])
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) { if (forceView !== prevForceView) {
@@ -60,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
@@ -88,6 +118,75 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<Logo size={28} showText={!collapsed} /> <Logo size={28} showText={!collapsed} />
</div> </div>
{/* Design Switcher */}
{!collapsed && designs.length > 0 && (
<div className="px-2 pt-2 pb-1 border-b border-border relative">
<button
onClick={() => setDesignSwitcherOpen((o) => !o)}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
>
{activeDesignId ? (() => {
const active = designs.find((d) => d.id === activeDesignId)
const Icon = resolveDesignIcon(active?.icon)
return <><Icon size={14} className="shrink-0 text-[#00d4ff]" /><span className="truncate text-foreground">{active?.name ?? 'Select Canvas'}</span></>
})() : <span className="text-muted-foreground">Select Canvas</span>}
</button>
{designSwitcherOpen && (
<>
{/* Overlay to close */}
<div className="fixed inset-0 z-40" onClick={() => setDesignSwitcherOpen(false)} />
<div className="absolute left-2 right-2 top-full mt-1 z-50 bg-[#21262d] border border-border rounded-md shadow-xl overflow-hidden">
{designs.map((d) => {
const Icon = resolveDesignIcon(d.icon)
const isActive = d.id === activeDesignId
return (
<div
key={d.id}
className={`group flex items-center transition-colors ${
isActive ? 'bg-[#00d4ff]/10 text-[#00d4ff]' : 'text-muted-foreground hover:bg-[#30363d]'
}`}
>
<button
onClick={() => { setActiveDesign(d.id); setDesignSwitcherOpen(false) }}
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-xs cursor-pointer hover:text-foreground"
>
<Icon size={14} className="shrink-0" />
<span className="truncate">{d.name}</span>
</button>
<button
aria-label={`Edit ${d.name}`}
title="Edit canvas"
onClick={() => { setDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
>
<Pencil size={12} />
</button>
<button
aria-label={`Delete ${d.name}`}
title="Delete canvas"
disabled={designs.length <= 1}
onClick={() => handleDesignDelete(d)}
className="shrink-0 p-1.5 pr-2 text-muted-foreground hover:text-[#f85149] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
>
<Trash2 size={12} />
</button>
</div>
)
})}
<div className="border-t border-border" />
<button
onClick={() => { setDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
>
<PlusCircle size={14} />
<span>New Canvas</span>
</button>
</div>
</>
)}
</div>
)}
{/* Views */} {/* Views */}
<nav className="flex flex-col gap-0.5 p-2"> <nav className="flex flex-col gap-0.5 p-2">
<SidebarItem <SidebarItem
@@ -121,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>
)} )}
@@ -155,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}
@@ -190,6 +278,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
</div> </div>
{!collapsed && <VersionBadge />} {!collapsed && <VersionBadge />}
<DesignModal
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
open={!!designModal}
onClose={() => setDesignModal(null)}
onSubmit={handleDesignSubmit}
initial={designModal?.mode === 'edit' && designModal.design
? { name: designModal.design.name, icon: designModal.design.icon ?? DEFAULT_DESIGN_ICON }
: undefined}
title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'}
submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'}
/>
</aside> </aside>
) )
} }
@@ -324,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: [] },
}) })
}) })
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useDesignStore } from '@/stores/designStore'
import type { Design } from '@/types'
function design(id: string, type: Design['design_type'] = 'network', name = id): Design {
return { id, name, design_type: type, created_at: '', updated_at: '' }
}
describe('designStore', () => {
beforeEach(() => {
useDesignStore.setState({ designs: [], activeDesignId: null, activeDesignType: null, loaded: false })
})
it('starts empty and not loaded', () => {
const s = useDesignStore.getState()
expect(s.designs).toEqual([])
expect(s.activeDesignId).toBeNull()
expect(s.activeDesignType).toBeNull()
expect(s.loaded).toBe(false)
})
it('setDesigns selects the first design as active and marks loaded', () => {
const a = design('a', 'network')
const b = design('b', 'electrical')
useDesignStore.getState().setDesigns([a, b])
const s = useDesignStore.getState()
expect(s.designs).toHaveLength(2)
expect(s.activeDesignId).toBe('a')
expect(s.activeDesignType).toBe('network')
expect(s.loaded).toBe(true)
})
it('setDesigns preserves the active design when it is still present', () => {
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
useDesignStore.getState().setActiveDesign('b')
// Re-list (e.g. after creating another design) — active id must not jump back to first.
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical'), design('c')])
const s = useDesignStore.getState()
expect(s.activeDesignId).toBe('b')
expect(s.activeDesignType).toBe('electrical')
})
it('setDesigns falls back to first when the active design was removed', () => {
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
useDesignStore.getState().setActiveDesign('b')
useDesignStore.getState().setDesigns([design('a')]) // 'b' deleted
const s = useDesignStore.getState()
expect(s.activeDesignId).toBe('a')
expect(s.activeDesignType).toBe('network')
})
it('setDesigns with an empty list clears the active selection', () => {
useDesignStore.getState().setDesigns([design('a')])
useDesignStore.getState().setDesigns([])
const s = useDesignStore.getState()
expect(s.activeDesignId).toBeNull()
expect(s.activeDesignType).toBeNull()
expect(s.loaded).toBe(true)
})
it('setActiveDesign updates id and resolves type', () => {
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
useDesignStore.getState().setActiveDesign('b')
const s = useDesignStore.getState()
expect(s.activeDesignId).toBe('b')
expect(s.activeDesignType).toBe('electrical')
})
it('setActiveDesign with an unknown id sets a null type', () => {
useDesignStore.getState().setDesigns([design('a')])
useDesignStore.getState().setActiveDesign('missing')
const s = useDesignStore.getState()
expect(s.activeDesignId).toBe('missing')
expect(s.activeDesignType).toBeNull()
})
it('getActiveDesign returns the active design or null', () => {
expect(useDesignStore.getState().getActiveDesign()).toBeNull()
const b = design('b', 'electrical')
useDesignStore.getState().setDesigns([design('a'), b])
useDesignStore.getState().setActiveDesign('b')
expect(useDesignStore.getState().getActiveDesign()).toEqual(b)
})
it('addDesign appends and makes the new design active', () => {
useDesignStore.getState().setDesigns([design('a')])
const b = design('b', 'electrical', 'Power')
useDesignStore.getState().addDesign(b)
const s = useDesignStore.getState()
expect(s.designs.map((d) => d.id)).toEqual(['a', 'b'])
expect(s.activeDesignId).toBe('b')
expect(s.activeDesignType).toBe('electrical')
})
it('updateDesign patches name and icon in place without touching others', () => {
useDesignStore.getState().setDesigns([design('a'), design('b')])
useDesignStore.getState().updateDesign('a', { name: 'Renamed', icon: 'server' })
const designs = useDesignStore.getState().designs
expect(designs.find((d) => d.id === 'a')).toMatchObject({ name: 'Renamed', icon: 'server' })
expect(designs.find((d) => d.id === 'b')!.name).toBe('b')
})
it('removeDesign drops a non-active design and keeps the active one', () => {
useDesignStore.getState().setDesigns([design('a'), design('b')])
useDesignStore.getState().setActiveDesign('a')
useDesignStore.getState().removeDesign('b')
const s = useDesignStore.getState()
expect(s.designs.map((d) => d.id)).toEqual(['a'])
expect(s.activeDesignId).toBe('a')
})
it('removeDesign reassigns active to the first remaining when the active is removed', () => {
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
useDesignStore.getState().setActiveDesign('a')
useDesignStore.getState().removeDesign('a')
const s = useDesignStore.getState()
expect(s.designs.map((d) => d.id)).toEqual(['b'])
expect(s.activeDesignId).toBe('b')
expect(s.activeDesignType).toBe('electrical')
})
it('removeDesign clears active when the last design is removed', () => {
useDesignStore.getState().setDesigns([design('a')])
useDesignStore.getState().setActiveDesign('a')
useDesignStore.getState().removeDesign('a')
const s = useDesignStore.getState()
expect(s.designs).toEqual([])
expect(s.activeDesignId).toBeNull()
expect(s.activeDesignType).toBeNull()
})
})
+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 }),
+72
View File
@@ -0,0 +1,72 @@
import { create } from 'zustand'
import type { Design, DesignType } from '@/types'
interface DesignState {
designs: Design[]
activeDesignId: string | null
activeDesignType: DesignType | null
loaded: boolean
setDesigns: (designs: Design[]) => void
setActiveDesign: (id: string) => void
getActiveDesign: () => Design | null
/** Append a new design and make it active. */
addDesign: (design: Design) => void
/** Patch an existing design in place (name/icon edits). */
updateDesign: (id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>) => void
/** Remove a design; if it was active, fall back to the first remaining one. */
removeDesign: (id: string) => void
}
export const useDesignStore = create<DesignState>((set, get) => ({
designs: [],
activeDesignId: null,
activeDesignType: null,
loaded: false,
setDesigns: (designs) =>
set((state) => {
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
? state.activeDesignId
: designs[0]?.id ?? null
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
}),
setActiveDesign: (id) =>
set((state) => {
const design = state.designs.find((d) => d.id === id)
return {
activeDesignId: id,
activeDesignType: design?.design_type ?? null,
}
}),
getActiveDesign: () => {
const { designs, activeDesignId } = get()
return designs.find((d) => d.id === activeDesignId) ?? null
},
addDesign: (design) =>
set((state) => ({
designs: [...state.designs, design],
activeDesignId: design.id,
activeDesignType: design.design_type,
})),
updateDesign: (id, patch) =>
set((state) => ({
designs: state.designs.map((d) => (d.id === id ? { ...d, ...patch } : d)),
})),
removeDesign: (id) =>
set((state) => {
const designs = state.designs.filter((d) => d.id !== id)
if (state.activeDesignId !== id) return { designs }
const next = designs[0] ?? null
return {
designs,
activeDesignId: next?.id ?? null,
activeDesignType: next?.design_type ?? null,
}
}),
}))
+1 -1
View File
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
describe('EDGE_TYPE_LABELS', () => { describe('EDGE_TYPE_LABELS', () => {
it('has an entry for every edge type', () => { it('has an entry for every edge type', () => {
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
expectedTypes.forEach((t) => { expectedTypes.forEach((t) => {
expect(EDGE_TYPE_LABELS).toHaveProperty(t) expect(EDGE_TYPE_LABELS).toHaveProperty(t)
}) })
+46 -2
View File
@@ -1,3 +1,15 @@
export type DesignType = 'network' | 'electrical'
export interface Design {
id: string
name: string
design_type: DesignType
/** Lucide icon key (see utils/designIcons). User-chosen; may be null on legacy rows. */
icon?: string | null
created_at: string
updated_at: string
}
export type NodeType = export type NodeType =
| 'isp' | 'isp'
| 'router' | 'router'
@@ -25,6 +37,20 @@ export type NodeType =
| 'zigbee_coordinator' | 'zigbee_coordinator'
| 'zigbee_router' | 'zigbee_router'
| 'zigbee_enddevice' | 'zigbee_enddevice'
| 'grid'
| 'ups'
| 'battery'
| 'generator'
| 'solar_panel'
| 'inverter'
| 'circuit_breaker'
| 'contactor'
| 'electrical_switch'
| 'socket'
| 'light'
| 'meter'
| 'transformer'
| 'load'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -37,7 +63,7 @@ export type TextPosition =
| 'bottom-center' | 'bottom-center'
| 'bottom-right' | 'bottom-right'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' | 'electrical'
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown' export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -106,8 +132,10 @@ export interface NodeData extends Record<string, unknown> {
*/ */
collapsed?: boolean collapsed?: boolean
custom_icon?: string custom_icon?: string
/** Number of bottom connection points, 1..48. Default 1 (centered). */ /** Number of bottom connection points, 1..64. Default 1 (centered). */
bottom_handles?: number bottom_handles?: number
/** Show a port number (1..N) above each bottom connection point. */
show_port_numbers?: boolean
/** Text node content (type === 'text') */ /** Text node content (type === 'text') */
text_content?: string text_content?: string
} }
@@ -157,6 +185,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
zigbee_coordinator: 'Zigbee Coordinator', zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router', zigbee_router: 'Zigbee Router',
zigbee_enddevice: 'Zigbee End Device', zigbee_enddevice: 'Zigbee End Device',
grid: 'Grid Connection',
ups: 'UPS',
battery: 'Battery',
generator: 'Generator',
solar_panel: 'Solar Panel',
inverter: 'Inverter',
circuit_breaker: 'Circuit Breaker',
contactor: 'Contactor',
electrical_switch: 'Switch',
socket: 'Socket / Outlet',
light: 'Light Fixture',
meter: 'Energy Meter',
transformer: 'Transformer',
load: 'Electrical Load',
} }
export const STATUS_COLORS: Record<NodeStatus, string> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -173,6 +215,8 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
vlan: 'VLAN', vlan: 'VLAN',
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
fibre: 'Fibre',
electrical: 'Electrical Wire',
} }
export interface NodeTypeStyle { export interface NodeTypeStyle {
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
describe('designIcons', () => {
it('exposes a non-empty, unique-keyed icon set', () => {
expect(DESIGN_ICONS.length).toBeGreaterThan(0)
const keys = DESIGN_ICONS.map((e) => e.key)
expect(new Set(keys).size).toBe(keys.length)
})
it('default icon key is part of the set', () => {
expect(DESIGN_ICONS.some((e) => e.key === DEFAULT_DESIGN_ICON)).toBe(true)
})
it('resolveDesignIcon returns the matching component for a known key', () => {
const entry = DESIGN_ICONS.find((e) => e.key === 'zap')!
expect(resolveDesignIcon('zap')).toBe(entry.icon)
})
it('resolveDesignIcon falls back to a component for unknown/empty keys', () => {
const fallback = resolveDesignIcon(undefined)
expect(typeof fallback).toBe('object')
expect(resolveDesignIcon('does-not-exist')).toBe(fallback)
expect(resolveDesignIcon(null)).toBe(fallback)
expect(resolveDesignIcon('')).toBe(fallback)
})
})
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { EDGE_DEFAULT_COLORS } from '../edgeColors' import { EDGE_DEFAULT_COLORS } from '../edgeColors'
import type { EdgeType } from '@/types' import type { EdgeType } from '@/types'
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
describe('EDGE_DEFAULT_COLORS', () => { describe('EDGE_DEFAULT_COLORS', () => {
it('has an entry for every EdgeType', () => { it('has an entry for every EdgeType', () => {
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
it('cluster default is proxmox orange', () => { it('cluster default is proxmox orange', () => {
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00') expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
}) })
it('fibre default is bright cyan', () => {
expect(EDGE_DEFAULT_COLORS.fibre).toBe('#22d3ee')
})
}) })
@@ -102,6 +102,15 @@ describe('exportCanvasToYaml', () => {
expect(entryA).not.toHaveProperty('clusterR') expect(entryA).not.toHaveProperty('clusterR')
}) })
it('serializes a fibre edge with linkType "fibre" (issue #21)', () => {
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
const edge = makeEdge('e1', 'sw', 's1', { type: 'fibre', label: 'sfp0' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'Switch')!
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'fibre', linkLabel: 'sfp0' }])
})
it('serializes multiple outgoing edges as links array', () => { it('serializes multiple outgoing edges as links array', () => {
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw') const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1') const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES) expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
}) })
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
expect(MAX_BOTTOM_HANDLES).toBeGreaterThanOrEqual(52)
})
it('clamps above MAX to MAX', () => { it('clamps above MAX to MAX', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES) expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES) expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
}) })
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(1)).toBe(1) expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24) expect(clampBottomHandles(24)).toBe(24)
expect(clampBottomHandles(48)).toBe(48) expect(clampBottomHandles(48)).toBe(48)
expect(clampBottomHandles(52)).toBe(52)
expect(clampBottomHandles(64)).toBe(64)
}) })
}) })
@@ -89,6 +89,22 @@ describe('parseYamlToCanvas', () => {
expect(edges[0].targetHandle).toBe('top-t') expect(edges[0].targetHandle).toBe('top-t')
}) })
it('imports a fibre link type onto the edge (issue #21)', () => {
const yaml = `
- nodeType: switch
label: "SW"
links:
- label: "SRV"
linkType: fibre
- nodeType: server
label: "SRV"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].type).toBe('fibre')
expect(edges[0].data?.type).toBe('fibre')
})
it('cluster edges have cluster-right→cluster-left handles', () => { it('cluster edges have cluster-right→cluster-left handles', () => {
const yaml = ` const yaml = `
- nodeType: proxmox - nodeType: proxmox
@@ -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([])
})
})
+1 -1
View File
@@ -6,7 +6,7 @@ const NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
] ]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
describe('THEME_ORDER', () => { describe('THEME_ORDER', () => {
+2
View File
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
width?: number | null width?: number | null
height?: number | null height?: number | null
bottom_handles?: number bottom_handles?: number
show_port_numbers?: boolean
} }
export interface ApiEdge { export interface ApiEdge {
@@ -114,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
width: n.measured?.width ?? n.width ?? null, width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null, height: n.measured?.height ?? n.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
} }
+44
View File
@@ -0,0 +1,44 @@
import {
LayoutDashboard, Zap, Network, Server, HardDrive, Cpu, Wifi, Router,
Database, Cloud, Home, Globe, Lightbulb, Factory, Plug, Boxes,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export interface DesignIconEntry {
key: string
label: string
icon: LucideIcon
}
/** Curated icon set offered when creating/editing a canvas design. Keys are
* stable strings persisted on `Design.icon`. */
export const DESIGN_ICONS: DesignIconEntry[] = [
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ key: 'network', label: 'Network', icon: Network },
{ key: 'zap', label: 'Electrical', icon: Zap },
{ key: 'server', label: 'Server', icon: Server },
{ key: 'harddrive', label: 'Storage', icon: HardDrive },
{ key: 'cpu', label: 'Compute', icon: Cpu },
{ key: 'wifi', label: 'Wireless', icon: Wifi },
{ key: 'router', label: 'Router', icon: Router },
{ key: 'database', label: 'Database', icon: Database },
{ key: 'cloud', label: 'Cloud', icon: Cloud },
{ key: 'home', label: 'Home', icon: Home },
{ key: 'globe', label: 'Internet', icon: Globe },
{ key: 'lightbulb', label: 'Lighting', icon: Lightbulb },
{ key: 'factory', label: 'Industrial', icon: Factory },
{ key: 'plug', label: 'Power', icon: Plug },
{ key: 'boxes', label: 'Cluster', icon: Boxes },
]
export const DEFAULT_DESIGN_ICON = 'dashboard'
const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
DESIGN_ICONS.map((e) => [e.key, e.icon]),
)
/** Resolve a persisted design icon key to a lucide component. Unknown/empty
* keys fall back to the dashboard icon so the UI never breaks on legacy data. */
export function resolveDesignIcon(key?: string | null): LucideIcon {
return (key && ICON_MAP[key]) || LayoutDashboard
}
+2
View File
@@ -7,4 +7,6 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
electrical: '#e3b341',
} }
+3 -3
View File
@@ -2,14 +2,14 @@
* Bottom handle configuration for multi-handle nodes. * Bottom handle configuration for multi-handle nodes.
* *
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible) * Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index N1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48') * index N1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
* *
* Invisible target handles follow the same pattern with a '-t' suffix: * Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t' * 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
*/ */
export const MIN_BOTTOM_HANDLES = 1 export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = 48 export const MAX_BOTTOM_HANDLES = 64
/** Returns the source handle ID at a given slot index. */ /** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string { export function bottomHandleId(idx: number): string {
+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 }]
}
+38 -22
View File
@@ -25,6 +25,8 @@ import {
Mail, MessageSquare, Phone, Mail, MessageSquare, Phone,
// Misc devices // Misc devices
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type, Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
// Electrical
ToggleLeft,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
@@ -154,32 +156,46 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
) )
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = { export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe, isp: Globe,
router: Router, router: Router,
firewall: Flame, firewall: Flame,
switch: Network, switch: Network,
server: Server, server: Server,
proxmox: Layers, proxmox: Layers,
vm: Box, vm: Box,
lxc: Container, lxc: Container,
nas: HardDrive, nas: HardDrive,
iot: Cpu, iot: Cpu,
ap: Wifi, ap: Wifi,
camera: Cctv, camera: Cctv,
printer: Printer, printer: Printer,
computer: Monitor, computer: Monitor,
laptop: Laptop, laptop: Laptop,
mobile: Smartphone, mobile: Smartphone,
cpl: PlugZap, cpl: PlugZap,
docker_host: Anchor, docker_host: Anchor,
docker_container: Package, docker_container: Package,
zigbee_coordinator: Radio, zigbee_coordinator: Radio,
zigbee_router: Zap, zigbee_router: Zap,
zigbee_enddevice: Lightbulb, zigbee_enddevice: Lightbulb,
generic: Circle, generic: Circle,
group: Circle, group: Circle,
groupRect: Circle, groupRect: Circle,
text: Type, text: Type,
grid: Zap,
ups: Power,
battery: BatteryCharging,
generator: Fan,
solar_panel: Sun,
inverter: Workflow,
circuit_breaker: PlugZap,
contactor: ToggleLeft,
electrical_switch: ToggleLeft,
socket: Plug,
light: Lightbulb,
meter: Gauge,
transformer: CircleDot,
load: Circle,
} }
/** Resolve the display icon for a node custom_icon takes priority over type default. /** Resolve the display icon for a node custom_icon takes priority over type default.
+105 -9
View File
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, text: { border: '#30363d', icon: '#e6edf3' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -86,6 +100,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
@@ -131,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#22d3ee', icon: '#22d3ee' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' },
text: { border: '#404040', icon: '#ffffff' }, text: { border: '#404040', icon: '#ffffff' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#0a0a0a', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -149,15 +179,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#22d3ee', vlan: '#22d3ee',
virtual: '#6b7280', virtual: '#6b7280',
cluster: '#fb923c', cluster: '#fb923c',
fibre: '#06b6d4',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#22d3ee', edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111', edgeLabelBackground:'#161b22',
edgeLabelColor: '#666666', edgeLabelColor: '#9ca3af',
edgeLabelBorder: '#1c1c1e', edgeLabelBorder: '#374151',
canvasBackground: '#000000', canvasBackground: '#030712',
canvasDotColor: '#1a1a1a', canvasDotColor: '#374151',
handleBackground: '#1c1c1e', handleBackground: '#374151',
handleBorder: '#444444', handleBorder: '#9ca3af',
reactFlowColorMode: 'dark', reactFlowColorMode: 'dark',
}, },
}, },
@@ -194,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#0284c7', icon: '#0284c7' }, groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' },
text: { border: '#cbd5e1', icon: '#1f2328' }, text: { border: '#cbd5e1', icon: '#1f2328' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#ffffff', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -212,15 +258,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#0284c7', vlan: '#0284c7',
virtual: '#9ca3af', virtual: '#9ca3af',
cluster: '#ea580c', cluster: '#ea580c',
fibre: '#0891b2',
electrical:'#d97706',
}, },
edgeSelectedColor: '#0284c7', edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff', edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a', edgeLabelColor: '#6b7280',
edgeLabelBorder: '#d0d7de', edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa', canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de', canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de', handleBackground: '#d0d7de',
handleBorder: '#9ca3af', handleBorder: '#6b7280',
reactFlowColorMode: 'light', reactFlowColorMode: 'light',
}, },
}, },
@@ -257,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ffff', icon: '#00ffff' }, groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' },
text: { border: '#3a3a6a', icon: '#ffffff' }, text: { border: '#3a3a6a', icon: '#ffffff' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#0f0f2a', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -275,6 +337,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00ffff', vlan: '#00ffff',
virtual: '#8888cc', virtual: '#8888cc',
cluster: '#ff8800', cluster: '#ff8800',
fibre: '#00e5ff',
electrical:'#ffff00',
}, },
edgeSelectedColor: '#00ffff', edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a', edgeLabelBackground:'#0a0a1a',
@@ -320,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ff41', icon: '#00ff41' }, groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' },
text: { border: '#003311', icon: '#00ff41' }, text: { border: '#003311', icon: '#00ff41' },
grid: { border: '#cc6600', icon: '#cc6600' },
ups: { border: '#00ff41', icon: '#00ff41' },
battery: { border: '#00ff41', icon: '#00ff41' },
generator: { border: '#ffcc00', icon: '#ffcc00' },
solar_panel: { border: '#ffcc00', icon: '#ffcc00' },
inverter: { border: '#aa00ff', icon: '#aa00ff' },
circuit_breaker: { border: '#ff0033', icon: '#ff0033' },
contactor: { border: '#ff0033', icon: '#ff0033' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#005500', icon: '#005500' },
light: { border: '#ffcc00', icon: '#ffcc00' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#aa00ff', icon: '#aa00ff' },
load: { border: '#ff69b4', icon: '#ff69b4' },
}, },
nodeCardBackground: '#001100', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
@@ -338,6 +416,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00cc33', vlan: '#00cc33',
virtual: '#004400', virtual: '#004400',
cluster: '#33ff66', cluster: '#33ff66',
fibre: '#00ffcc',
electrical:'#66ff33',
}, },
edgeSelectedColor: '#00ff41', edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100', edgeLabelBackground:'#001100',
@@ -383,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, text: { border: '#30363d', icon: '#e6edf3' },
grid: { border: '#ff6e00', icon: '#ff6e00' },
ups: { border: '#39d353', icon: '#39d353' },
battery: { border: '#39d353', icon: '#39d353' },
generator: { border: '#e3b341', icon: '#e3b341' },
solar_panel: { border: '#e3b341', icon: '#e3b341' },
inverter: { border: '#a855f7', icon: '#a855f7' },
circuit_breaker: { border: '#f85149', icon: '#f85149' },
contactor: { border: '#f85149', icon: '#f85149' },
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
socket: { border: '#8b949e', icon: '#8b949e' },
light: { border: '#e3b341', icon: '#e3b341' },
meter: { border: '#00d4ff', icon: '#00d4ff' },
transformer: { border: '#a855f7', icon: '#a855f7' },
load: { border: '#ec4899', icon: '#ec4899' },
}, },
nodeCardBackground: '#21262d', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -401,6 +495,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
electrical: '#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
+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"}])