Compare commits

...

35 Commits

Author SHA1 Message Date
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
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
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
Pouzor b5628e18fa chore(release): bump version to 2.2.0 2026-05-29 10:42:19 +02:00
Rémy dc70805673 Merge pull request #166 from CodeWarrior4Life/pr-laptop-mobile-types
Add laptop and mobile node types
2026-05-29 10:25:35 +02:00
Pouzor 05c24d622b style(themes): align laptop/mobile keys + theme-coherent mobile accents
- Pad laptop:/mobile: to match each theme's existing key-column width
  (dark, light, custom were off by 1-4 spaces).
- Replace hardcoded #ec4899 mobile pink with palette-coherent accents:
  light  -> #db2777 (contrast on light bg)
  neon   -> #ff3399 (neon family)
  matrix -> #00cc66 (green palette)
  default/dark/custom keep #ec4899.
2026-05-29 10:12:04 +02:00
Rémy 541e25327b Merge pull request #158 from pranjal-joshi/feat/collapsible
feat: add collapsible/expandable zones for canvas decluttering
2026-05-29 09:38:20 +02:00
Pouzor 9823be9d78 fix(liveview): apply collapse filter in read-only canvas
LiveView.tsx passed raw nodes/edges from the store straight to
ReactFlow, bypassing the collapse pipeline that CanvasContainer applies
in the editor. Result: a group or zone marked collapsed in edit mode
still showed all its contents on /view?key=... and in standalone live
view. The flag was being persisted and read correctly — only the
read-only canvas ignored it.

Reuse the same memoized computeCollapseInfo + rewireEdgesForCollapse
pipeline. Add two regression tests that load a /liveview payload with
a collapsed group and assert the children never reach ReactFlow.
2026-05-29 09:27:45 +02:00
Pouzor b71c96897a fix(canvas): make collapse reachable + persist for every node type
Three connected bugs in PR #158's collapse feature:

1. Toggle wired to the wrong component
   The chevron was on GroupRectNode and computed children via React Flow
   parentId. But in this codebase parentId is set by createGroup() on
   type=group nodes, not on groupRect zones — zones are decorative
   rectangles. Result: childrenCount was always 0 on every zone and the
   button never rendered, so the feature was unreachable from the UI.

   Fix:
   - Add the same chevron toggle to GroupNode (the actual container).
     parentId children are already known there, so the existing BFS in
     computeCollapseInfo hides them when data.collapsed flips.
   - For GroupRectNode, switch childrenCount to spatial containment so
     drawn zones also work: hit-test other nodes' bbox centres against
     the zone bbox.

2. Visibility filter ignored spatial zones
   Extend computeCollapseInfo with a second pass that hides every node
   whose centre lies inside a collapsed groupRect, plus the parentId
   subtrees of those nodes (so a Proxmox host inside a collapsed zone
   takes its VMs/LXCs with it). Edge rewiring routes vanished endpoints
   to the same visible zone via a unified hiddenBy map populated by both
   passes.

3. Save dropped data.collapsed for every type except groupRect
   The DevTools payload was the smoking gun: for a type=group node the
   serializer wrote custom_colors: {show_border: true} with no collapsed
   key, so the backend stored a stale false on every save. Only the
   groupRect branch of serializeNode/deserializeApiNode stashed and
   hoisted the flag. Move the stash + hoist to the general branch too
   (backend's custom_colors is dict[str, Any] so no schema change).

Tests: 11 new cases for spatial containment + GroupNode toggle UI, and
4 round-trip cases for collapse on non-groupRect types.
2026-05-29 08:58:36 +02:00
Pouzor 20e1820a4e refactor(canvas): promote collapsed to first-class NodeData field + edge rewire
Three follow-ups to PR #158 review:

1. Promote collapsed to NodeData.collapsed
   The flag was previously stashed inside NodeData.custom_colors, which is
   a colors/style object — semantically wrong. Move it to a first-class
   boolean on NodeData. Persistence keeps the existing API shape: serialize
   writes it into the custom_colors blob (alongside width/height/z_order,
   matching how groupRect already stashes layout metadata), and deserialize
   hoists it back. Legacy saves from the original PR shape load correctly.

2. Re-route cross-zone edges to the collapsed ancestor
   Previously any edge touching a hidden node was dropped, so a Zigbee
   coordinator outside a collapsed mesh lost all visible links to it.
   rewireEdgesForCollapse now walks each endpoint up the parentId chain to
   its nearest visible ancestor, surfaces a single stub edge on the
   collapsed zone, de-dupes parallel rewires (a 20-device mesh becomes one
   stub, not twenty), and drops edges that would self-loop on a zone or
   reference an orphan.

3. Revert package-lock.json churn
   The 63-line diff from the original PR was npm-version drift (libc
   arrays stripped from optional deps), unrelated to the feature.

Tests:
- canvasStore.collapse: updated to assert on data.collapsed.
- collapseFilter: 8 cases for visibility + 7 for edge rewire, covering
  cross-boundary, nested collapse, sibling self-loop, mesh dedup, and
  orphan endpoints.
- canvasSerializer.collapse: round-trip + legacy-shape compat.
2026-05-29 01:34:21 +02:00
Pouzor 517486ff79 perf(canvas): memoize collapse visibility filter + add tests
Extract getVisibleNodeIds/filterVisibleEdges from CanvasContainer into
src/utils/collapseFilter.ts. Replace inline O(n^2) BFS (nested array
.find per node) with O(n) traversal backed by parentId->children and
id->node Maps, and wrap consumer calls in useMemo so visibility is
recomputed only when nodes/edges change rather than on every render.

Add 12 unit tests covering the filter logic that the original PR left
untested: single-level collapse, multi-level subtree hiding via
collapsed ancestor, sibling isolation when an inner zone is collapsed,
empty zones, missing custom_colors, insertion-order independence, and
edge filtering for hidden source/target.
2026-05-29 01:09:46 +02:00
Cyril Grosse III b5b1056ae6 Add laptop and mobile node types
Personal computing devices (laptops, phones, tablets) currently collapse
into the generic icon because the type vocabulary has no entries for
them. This adds two new NodeTypes with Lucide icons:

- laptop  -> Laptop icon (reuses the computer accent color per theme)
- mobile  -> Smartphone icon (new pink accent #ec4899 across themes)

Touches:
- types/index.ts                    NodeType union + NODE_TYPE_LABELS
- utils/nodeIcons.ts                Lucide import + ICON_REGISTRY +
                                    NODE_TYPE_DEFAULT_ICONS
- utils/themes.ts                   nodeAccents in all 6 themes
- canvas/nodes/index.tsx            LaptopNode + MobileNode wrappers
- canvas/nodes/nodeTypes.ts         register in react-flow nodeTypes
- modals/NodeModal.tsx              new "Personal" type group
- modals/CustomStyleModal.tsx       expose new types in style editor
- types/__tests__/types.test.ts     enumerate new types
- utils/__tests__/themes.test.ts    enumerate new types

Backwards-compatible: existing nodes typed as 'generic', 'server', etc.
keep rendering exactly as before. No data migration required.
2026-05-28 15:55:46 -04:00
Rémy 66a9a57861 Merge pull request #163 from Pouzor/feat/lxc-mcp-install-script
feat(scripts): LXC/bare-metal MCP install script
2026-05-28 00:50:49 +02:00
Pouzor 529c75a175 feat(scripts): lxc-mcp-install env-var overrides + repo clone fallback
- All prompted values overridable via env vars (MCP_API_KEY,
  MCP_SERVICE_KEY, BACKEND_URL, INSTALL_DIR, etc.).
- Clone the repo into INSTALL_DIR if it isn't already present, so the
  script can be fetched and run directly inside a fresh LXC created by
  the community-scripts/ProxmoxVE helper (no manual git clone first).
- README: clarify the Proxmox flow (community-scripts creates the LXC,
  user runs this script inside it).
2026-05-27 22:27:54 +02:00
Pouzor fc765fa255 ci(docker): publish homelable-mcp image to GHCR
Add mcp matrix entry in docker-publish workflow so the MCP server
image is built and pushed alongside backend/frontend. Also wire the
prebuilt compose file so users can pull and run it directly.

Closes #162
2026-05-27 21:32:55 +02:00
Pouzor 77159ce1cd feat(scripts): add LXC/bare-metal MCP install script
Adds scripts/lxc-mcp-install.sh for Proxmox LXC and other non-Docker
hosts. Creates a homelable-mcp systemd service, prompts for
MCP_API_KEY / MCP_SERVICE_KEY (auto-generated on Enter), and skips
prompts when mcp/.env already exists so user edits are preserved.

Closes #132
2026-05-26 15:13:14 +02:00
pranjal-joshi 69aa8256f0 fix: add null-safe default for nodes array in GroupRectNode
- Use nullish coalescing operator to provide empty array default
- Prevents 'Cannot read properties of undefined' error when nodes is undefined
- Fixes failing GroupRectNode tests that don't provide mock nodes

Co-authored-by: CyberClaw <noreply@openclaw.ai>
2026-05-18 03:03:28 +00:00
pranjal-joshi 78b43a300f fix: simplify collapse tests to focus on unit tests, avoid act() warnings
- Replace component render tests with unit tests on types and state logic
- Tests now verify: type definitions, optional properties, toggle logic, nesting support
- Removes complex mocking and React component testing that triggers act() warnings
- Full integration testing is covered by CanvasContainer tests
- Reduces test file from 142 lines to focused unit tests

Co-authored-by: CyberClaw <noreply@openclaw.ai>
2026-05-18 02:59:36 +00:00
pranjal-joshi 995de26591 fix: remove any types and unused variables in test file
- Replace 'any' types with proper TypeScript types (unknown, jest.Mock, Record)
- Remove unused 'container' destructuring variable
- Use document.querySelector instead of container.querySelector
- Fixes ESLint errors: @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars

Co-authored-by: CyberClaw <noreply@openclaw.ai>
2026-05-18 02:40:05 +00:00
pranjal-joshi 525dfe5ece feat: add collapsible/expandable zones for canvas decluttering
- Add collapsed state to NodeData.custom_colors (type=groupRect only)
- Implement toggleNodeCollapsed action in canvasStore (Zustand)
- Extend GroupRectNode UI with smooth chevron toggle button
  - Rotating chevron icon (↓ → when collapsed)
  - Shows '+N' badge when zone is hidden
  - Reduces zone opacity to 0.6 when collapsed
  - All transitions target 60 FPS (ease-out 200ms)
- Filter child nodes/edges in CanvasContainer based on parent collapse state
  - Breadth-first traversal handles multi-level nesting
  - Connecting edges to hidden nodes are automatically hidden
- Add comprehensive test coverage
  - Store: toggleNodeCollapsed state mutation, unsaved flag
  - Component: chevron rendering, click handlers, opacity transitions
- Persist collapsed state via YAML serialization (part of custom_colors)

Benefits:
- Declutter large Zigbee meshes, multi-building networks
- Preserve layout structure without deleting nodes
- Smooth 60 FPS transitions for UX polish

CONTRIBUTING.md compliance:
- Strict TypeScript types, no 'any'
- Zustand store pattern, no prop drilling
- Tests for store logic and component behavior
- Frontend linting requirements met

Co-authored-by: CyberClaw <noreply@openclaw.ai>
2026-05-17 19:37:04 +00:00
Pranjal Joshi d066f37e88 Merge branch 'Pouzor:main' into main 2026-05-18 00:51:14 +05:30
Pranjal Joshi 004623bae5 Merge pull request #1 from pranjal-joshi/feat/zigbee
feat: add Zigbee2MQTT network map importer
2026-05-18 00:50:30 +05:30
Pouzor f8635df1c5 bump: version 2.1.1 2026-05-17 00:30:21 +02:00
Rémy 1cc9b7c52f Merge pull request #155 from Pouzor/fix/liveview-parity
fix(liveview): nest docker children, apply theme & custom style
2026-05-16 22:28:38 +02:00
Pouzor fdf2b1f2be chore(liveview): include setTheme/setCustomStyle in effect deps 2026-05-16 21:52:52 +02:00
Pouzor 5630e7d202 fix(liveview): nest docker children, apply theme & custom style
- Live view rebuilt parent map from proxmox/group only, so docker_container
  (and any vm/lxc with container_mode=true) lost parentId and rendered
  outside its host. Match App.tsx logic: include any node with
  container_mode=true.
- Live view ignored viewport.theme_id and custom_style on load, so the
  shared canvas always rendered in default theme. Apply both like the
  authenticated canvas does.
- Backend /liveview response now includes custom_style from CanvasState.

Adds regression tests on both ends.
2026-05-16 21:49:55 +02:00
Pouzor be705f0cb9 bump: version 2.1.0 2026-05-16 18:18:46 +02:00
69 changed files with 2879 additions and 141 deletions
+9 -2
View File
@@ -16,14 +16,21 @@ jobs:
matrix:
include:
- image: ghcr.io/pouzor/homelable-backend
context: .
dockerfile: Dockerfile.backend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend
context: .
dockerfile: Dockerfile.frontend
build_args: ""
- image: ghcr.io/pouzor/homelable-frontend-standalone
context: .
dockerfile: Dockerfile.frontend
build_args: "VITE_STANDALONE=true"
- image: ghcr.io/pouzor/homelable-mcp
context: ./mcp
dockerfile: Dockerfile.mcp
build_args: ""
steps:
- uses: actions/checkout@v4
@@ -55,8 +62,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
context: ${{ matrix.context }}
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
+6
View File
@@ -223,6 +223,12 @@ docker compose up -d mcp
# MCP server is now listening on http://<your-homelab-ip>:8001
```
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
**3. Configure your AI client:**
**Claude Code** — run this command in your terminal:
+1 -1
View File
@@ -1 +1 @@
2.0.3
2.2.0
+42 -17
View File
@@ -1,13 +1,14 @@
import uuid
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
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, Edge, Node
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
@@ -16,10 +17,20 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
async def load_canvas(
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
db: AsyncSession = Depends(get_db),
_: 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}
return CanvasStateResponse(
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")
async def save_canvas(
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_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas
existing_nodes = (await db.execute(select(Node))).scalars().all()
# Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for node in existing_nodes:
if node.id not in incoming_node_ids:
await db.delete(node)
# Delete edges removed from canvas
existing_edges = (await db.execute(select(Edge))).scalars().all()
# Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for edge in existing_edges:
if edge.id not in incoming_edge_ids:
await db.delete(edge)
@@ -53,29 +74,33 @@ async def save_canvas(
# Upsert nodes
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node:
for field, value in node_data.model_dump().items():
for field, value in payload.items():
setattr(db_node, field, value)
else:
db.add(Node(**node_data.model_dump()))
db.add(Node(**payload))
# Upsert edges
for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge:
for field, value in edge_data.model_dump().items():
for field, value in payload.items():
setattr(db_edge, field, value)
else:
db.add(Edge(**edge_data.model_dump()))
db.add(Edge(**payload))
# Upsert viewport + custom style
state = await db.get(CanvasState, 1)
state = await db.get(CanvasState, design_id)
if state:
state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc)
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()
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()
+13 -4
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
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.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
@@ -18,6 +18,7 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def liveview_canvas(
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),
) -> CanvasStateResponse:
"""Read-only public canvas endpoint.
@@ -30,12 +31,20 @@ async def liveview_canvas(
if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
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}
custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=custom_style,
)
+19 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
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.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan
@@ -118,6 +118,10 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> 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(
select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids),
@@ -144,6 +148,7 @@ async def bulk_approve_devices(
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_zigbee else ("ping" if device.ip else None),
design_id=default_design_id,
)
db.add(node)
created_nodes.append(node)
@@ -227,6 +232,12 @@ async def approve_device(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> 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)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
@@ -247,6 +258,7 @@ async def approve_device(
) if _is_zigbee else (node_data.properties or []),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target,
design_id=node_design_id,
)
db.add(node)
await db.flush()
@@ -328,12 +340,18 @@ async def _resolve_pending_links_for_ieee(
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link)
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(
source=src_id,
target=tgt_id,
type="iot",
source_handle="bottom",
target_handle="top-t",
design_id=edge_design_id,
)
db.add(edge)
await db.flush()
+6 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
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.zigbee import (
ZigbeeCoordinatorOut,
@@ -138,6 +138,10 @@ async def _persist_pending_import(
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.
"""
# 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_existed = False
pending_created = 0
@@ -174,6 +178,7 @@ async def _persist_pending_import(
ieee_address=ieee,
services=[],
properties=props,
design_id=default_design_id,
)
db.add(node)
await db.flush()
+99 -5
View File
@@ -1,5 +1,7 @@
import json as _json
import logging
import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
@@ -168,20 +170,113 @@ async def init_db() -> None:
except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- 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):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
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)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL"
)
for row in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
for r in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
props = []
visible = bool(show_hardware)
if cpu_model:
@@ -192,7 +287,6 @@ async def init_db() -> None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id),
+14 -1
View File
@@ -16,12 +16,24 @@ def _uuid() -> str:
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):
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: 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)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
@@ -61,6 +73,7 @@ class Edge(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: 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")
label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer)
@@ -77,7 +90,7 @@ class Edge(Base):
class CanvasState(Base):
__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)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
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.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.core.config import settings
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(edges.router, prefix="/api/v1/edges", tags=["edges"])
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(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+1
View File
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
edges: list[EdgeSave] = []
viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
design_id: str | None = None
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):
pass
design_id: str | None = None
class EdgeUpdate(BaseModel):
+3 -1
View File
@@ -34,7 +34,7 @@ class NodeBase(BaseModel):
class NodeCreate(NodeBase):
pass
design_id: str | None = None
class NodeUpdate(BaseModel):
@@ -68,6 +68,8 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase):
id: str
design_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None
response_time_ms: int | None = None
created_at: datetime
+1 -1
View File
@@ -15,7 +15,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917
websockets==13.1
httpx==0.27.2
zeroconf==0.131.0
zeroconf==0.149.7
aiomqtt==2.3.0
# Dev
+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"
+22
View File
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
assert nodes[0]["label"] == "Live Node"
# ── custom_style + theme propagation ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
settings.liveview_key = "test-key"
headers = await auth_headers()
payload = {
"nodes": [],
"edges": [],
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
}
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
res = await client.get("/api/v1/liveview?key=test-key")
assert res.status_code == 200
body = res.json()
assert body["viewport"].get("theme_id") == "matrix"
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
# ── Re-disable after enabling ─────────────────────────────────────────────────
@pytest.mark.asyncio
+14
View File
@@ -24,6 +24,20 @@ services:
networks:
- homelable
mcp:
image: ghcr.io/pouzor/homelable-mcp:latest
restart: unless-stopped
ports:
- "8001:8001"
env_file:
- .env
environment:
BACKEND_URL: "http://backend:8000"
depends_on:
- backend
networks:
- homelable
volumes:
backend_data:
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.0.3",
"version": "2.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "2.0.3",
"version": "2.2.0",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.0.3",
"version": "2.2.0",
"type": "module",
"scripts": {
"dev": "vite",
+101 -28
View File
@@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client'
import { canvasApi, designsApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -44,6 +45,7 @@ export default function App() {
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling()
@@ -70,29 +72,75 @@ export default function App() {
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope
const handleSave = useCallback(async () => {
// Declare handleSave before the Ctrl+S effect so it is in scope.
// 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 {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved()
toast.success('Canvas saved')
return
return true
}
const nodesToSave = nodes.map(serializeNode)
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()
toast.success('Canvas saved')
return true
} catch {
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
const handleSaveRef = useRef(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)
useEffect(() => {
if (STANDALONE) {
@@ -112,28 +160,53 @@ export default function App() {
return
}
if (!isAuthenticated) return
canvasApi.load()
.then((res) => {
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) {
// Build a map of container mode nodes to know if children should be nested
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))
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
loadDesignsAndCanvas()
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
// Reload canvas when active design changes (after initial load)
const initialLoadDone = useRef(false)
const prevDesignRef = useRef<string | null>(null)
// Set while we programmatically revert activeDesignId after a failed save, so
// the re-entrant effect run skips save/load and just re-syncs the refs.
const revertingRef = useRef(false)
useEffect(() => {
if (revertingRef.current) {
revertingRef.current = false
prevDesignRef.current = activeDesignId
return
}
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
const oldId = prevDesignRef.current
// 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
if (oldId && oldId !== activeDesignId && oldStillExists) {
// 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
const undoRef = useRef(undo)
+1 -1
View File
@@ -127,7 +127,7 @@ describe('api/client', () => {
it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas')
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
})
it('canvasApi.save POSTs to /canvas/save with payload', () => {
+14 -1
View File
@@ -28,12 +28,16 @@ export const authApi = {
}
export const canvasApi = {
load: () => api.get('/canvas'),
load: (design_id?: string) => {
const params = design_id ? { design_id } : {}
return api.get('/canvas', { params })
},
save: (payload: {
nodes: object[]
edges: object[]
viewport: object
custom_style?: object | null
design_id?: string | null
}) => api.post('/canvas/save', payload),
}
@@ -89,6 +93,15 @@ export const settingsApi = {
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 = {
testConnection: (data: {
mqtt_host: string
+25 -7
View File
@@ -10,7 +10,7 @@
* Clicking a node with an IP opens http://<ip> in a new tab.
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ReactFlowProvider,
ReactFlow,
@@ -28,8 +28,9 @@ import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { liveviewApi } from '@/api/client'
import type { NodeData } from '@/types'
import type { NodeData, CustomStyleDef } from '@/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STORAGE_KEY = 'homelable_canvas'
@@ -40,6 +41,8 @@ function LiveViewCanvas() {
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
const { fitView } = useReactFlow()
const activeTheme = useThemeStore((s) => s.activeTheme)
const setTheme = useThemeStore((s) => s.setTheme)
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
const theme = THEMES[activeTheme]
// Derive initial view state synchronously (avoids calling setState inside an effect):
// - standalone → always ready (localStorage, no key required)
@@ -73,9 +76,12 @@ function LiveViewCanvas() {
const { nodes: apiNodes, edges: apiEdges } = res.data
const proxmoxMap = new Map<string, boolean>(
(apiNodes as ApiNode[])
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
.map((n: ApiNode) => [n.id, true])
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
@@ -87,7 +93,7 @@ function LiveViewCanvas() {
const detail: string = err.response.data?.detail ?? ''
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
})
}, [loadCanvas])
}, [loadCanvas, setTheme, setCustomStyle])
useEffect(() => {
if (!fitViewPending || nodes.length === 0) return
@@ -103,6 +109,18 @@ function LiveViewCanvas() {
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
}, [])
// Apply collapse-state filtering — same pipeline the editor canvas uses,
// so a collapsed group/zone hides its contents in live view too.
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
const visibleNodes = useMemo(
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
[nodes, collapseInfo],
)
const visibleEdges = useMemo(
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
[edges, nodes, collapseInfo],
)
if (viewState === 'loading') {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
@@ -131,8 +149,8 @@ function LiveViewCanvas() {
return (
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodes={visibleNodes}
edges={visibleEdges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodesDraggable={false}
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, waitFor } from '@testing-library/react'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
// ── Capture the props ReactFlow is rendered with ──────────────────────────
const rfPropsSpy = vi.fn()
vi.mock('@xyflow/react', () => ({
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: (props: unknown) => {
rfPropsSpy(props)
return <div data-testid="react-flow" />
},
Background: () => null,
Controls: () => null,
BackgroundVariant: { Dots: 'dots' },
ConnectionMode: { Loose: 'loose' },
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
useReactFlow: () => ({ fitView: vi.fn() }),
}))
vi.mock('@xyflow/react/dist/style.css', () => ({}))
vi.mock('@/api/client', () => ({ liveviewApi: { load: vi.fn() } }))
import { liveviewApi } from '@/api/client'
import LiveView from '../LiveView'
function setSearch(params: string) {
Object.defineProperty(window, 'location', {
writable: true,
value: { ...window.location, search: params, pathname: '/view' },
})
}
/** Build a /liveview API response with the given nodes/edges. */
const apiResponse = (nodes: unknown[], edges: unknown[] = []) => ({
data: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } },
})
const apiNode = (
id: string,
parent_id?: string,
collapsed?: boolean,
type = 'server',
) => ({
id,
type,
label: id,
status: 'online',
services: [],
pos_x: 0,
pos_y: 0,
parent_id: parent_id ?? null,
container_mode: type === 'group',
custom_colors: collapsed !== undefined ? { collapsed } : null,
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z',
})
describe('LiveView — applies collapse filter to the rendered canvas', () => {
beforeEach(() => {
rfPropsSpy.mockClear()
setSearch('?key=valid')
vi.mocked(liveviewApi.load).mockReset()
})
it('hides children of a collapsed group container in view-only mode', async () => {
vi.mocked(liveviewApi.load).mockResolvedValue(
apiResponse([apiNode('g1', undefined, true, 'group'), apiNode('c1', 'g1')]),
)
render(<LiveView />)
await waitFor(() => {
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
| { nodes: Node<NodeData>[] }
| undefined
expect(last?.nodes.length).toBeGreaterThan(0)
})
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
}
const ids = last.nodes.map((n) => n.id)
expect(ids).toContain('g1')
expect(ids).not.toContain('c1')
})
it('shows children when the group is expanded', async () => {
vi.mocked(liveviewApi.load).mockResolvedValue(
apiResponse([apiNode('g1', undefined, false, 'group'), apiNode('c1', 'g1')]),
)
render(<LiveView />)
await waitFor(() => {
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
| { nodes: Node<NodeData>[] }
| undefined
expect(last?.nodes.length).toBeGreaterThan(1)
})
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
nodes: Node<NodeData>[]
}
const ids = last.nodes.map((n) => n.id)
expect(ids).toContain('g1')
expect(ids).toContain('c1')
})
})
@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ────────────────────────────────────────────────
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
})
// ── Nested children (docker_container inside docker_host) ────────────────
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
setSearch('?key=valid')
const nestedPayload = {
data: {
nodes: [
{
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
services: [], pos_x: 0, pos_y: 0, container_mode: true,
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
},
{
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
},
],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
}
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
expect(ctr?.parentId).toBe('host')
expect(ctr?.extent).toBe('parent')
})
// ── Theme + custom_style applied from payload ────────────────────────────
it('applies viewport.theme_id and custom_style from the payload', async () => {
setSearch('?key=valid')
const styledPayload = {
data: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
},
}
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
expect(useThemeStore.getState().activeTheme).toBe('matrix')
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
})
// ── No editing props passed ───────────────────────────────────────────────
it('does not show any Access Denied when key is valid', async () => {
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ReactFlow,
Background,
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
@@ -55,6 +56,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
// Filter nodes and edges based on collapsed state (memoized — O(n)).
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
const visibleNodes = useMemo(
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
[nodes, collapseInfo],
)
const visibleEdges = useMemo(
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
[edges, nodes, collapseInfo],
)
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
if (e.ctrlKey || e.metaKey) {
setSelectedNode(null)
@@ -90,8 +102,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
return (
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
<ReactFlow
nodes={nodes}
edges={edges}
nodes={visibleNodes}
edges={visibleEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnectProp}
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
vi.mock('@/utils/handleUtils', () => ({
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,
}))
@@ -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', () => {
it('does not render service toggle button on the node', () => {
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
@@ -42,6 +42,7 @@ function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, s
nodes: storeNodes,
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
toggleNodeCollapsed: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
return render(
@@ -134,4 +135,51 @@ describe('GroupNode', () => {
renderGroupNode()
expect(screen.queryByText(/●/)).toBeNull()
})
it('renders a collapse toggle when the group has parentId children', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
{ id: 'c2', parentId: 'g1', data: { status: 'online' } },
]
renderGroupNode({}, storeNodes)
expect(screen.getByTitle('Hide 2 items')).toBeDefined()
})
it('flips the toggle title when collapsed', () => {
const storeNodes = [
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
]
renderGroupNode({ data: makeGroupNode({ collapsed: true }).data }, storeNodes)
expect(screen.getByTitle('Show 1 hidden items')).toBeDefined()
})
it('calls toggleNodeCollapsed when the toggle is clicked', () => {
const toggleNodeCollapsed = vi.fn()
const storeNodes = [{ id: 'c1', parentId: 'g1', data: { status: 'online' } }]
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: storeNodes,
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
toggleNodeCollapsed,
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(
<GroupNode
id="g1"
data={makeGroupNode().data}
selected={false}
dragging={false}
zIndex={1}
isConnectable={true}
positionAbsoluteX={0}
positionAbsoluteY={0}
/>,
)
fireEvent.click(screen.getByTitle('Hide 1 items'))
expect(toggleNodeCollapsed).toHaveBeenCalledWith('g1')
})
it('does not render the toggle when the group has no children', () => {
renderGroupNode()
expect(screen.queryByTitle(/Hide.*items|Show.*hidden/)).toBeNull()
})
})
@@ -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,
virtual: HomelableEdge,
cluster: HomelableEdge,
fibre: HomelableEdge,
electrical: HomelableEdge,
}
@@ -323,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
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
@@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
const targetId = `${sourceId}-t`
return (
<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
type="source"
position={Position.Bottom}
@@ -1,13 +1,14 @@
import { useState } from 'react'
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
import { Layers, Pencil, Check, X } from 'lucide-react'
import { Layers, Pencil, Check, X, ChevronDown } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { STATUS_COLORS, type NodeData } from '@/types'
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
const { nodes, updateNode, snapshotHistory, toggleNodeCollapsed } = useCanvasStore()
const isCollapsed = data.collapsed ?? false
const activeTheme = useThemeStore((s) => s.activeTheme)
const theme = THEMES[activeTheme]
const showBorder = data.custom_colors?.show_border !== false
@@ -138,6 +139,28 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
</button>
)}
{/* Collapse / expand toggle */}
{children.length > 0 && (
<button
className="nodrag"
onClick={(e) => { e.stopPropagation(); toggleNodeCollapsed(id) }}
title={isCollapsed ? `Show ${children.length} hidden items` : `Hide ${children.length} items`}
style={{
color: '#00d4ff',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 1,
display: 'flex',
alignItems: 'center',
transition: 'transform 0.2s ease-out',
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
}}
>
<ChevronDown size={11} />
</button>
)}
{/* Status summary */}
{children.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { ChevronDown } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { getZoneSpatialChildren } from '@/utils/collapseFilter'
import type { NodeData, TextPosition } from '@/types'
const FONT_FAMILIES: Record<string, string> = {
@@ -36,9 +38,12 @@ const HANDLE_SIDES = [
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
const toggleNodeCollapsed = useCanvasStore((s) => s.toggleNodeCollapsed)
const nodes = useCanvasStore((s) => s.nodes)
const [hovered, setHovered] = useState(false)
const rc = data.custom_colors ?? {}
const isCollapsed = data.collapsed ?? false
const borderColor = rc.border ?? '#00d4ff'
const borderStyle = rc.border_style ?? 'solid'
const borderWidth = rc.border_width ?? 2
@@ -50,6 +55,13 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos]
// Count children for collapse badge — groupRect zones don't parent their
// contents via React Flow parentId, so we hit-test by spatial containment.
const selfNode = (nodes ?? []).find((n) => n.id === id)
const childrenCount = selfNode
? getZoneSpatialChildren(selfNode, nodes ?? []).length
: 0
const outsideJustify = textPos.includes('right') ? 'flex-end'
: (textPos.includes('center') || textPos === 'center') ? 'center'
: 'flex-start'
@@ -118,6 +130,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
borderRadius: 10,
boxSizing: 'border-box',
cursor: 'default',
transition: 'opacity 0.2s ease-out, filter 0.2s ease-out',
opacity: isCollapsed ? 0.6 : 1,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
@@ -126,6 +140,51 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
setEditingGroupRectId(id)
}}
>
{childrenCount > 0 && (
<button
className="nodrag"
onClick={(e) => {
e.stopPropagation()
toggleNodeCollapsed(id)
}}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
padding: 0,
background: 'rgba(0, 212, 255, 0.1)',
border: '1px solid rgba(0, 212, 255, 0.3)',
borderRadius: 4,
color: borderColor,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease-out, transform 0.2s ease-out',
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
}}
title={isCollapsed ? `Show ${childrenCount} hidden items` : `Hide ${childrenCount} items`}
>
<ChevronDown size={14} />
</button>
)}
{isCollapsed && childrenCount > 0 && (
<span
style={{
position: 'absolute',
bottom: 6,
right: 6,
fontSize: 10,
color: borderColor,
opacity: 0.7,
userSelect: 'none',
}}
>
+{childrenCount}
</span>
)}
{labelPosition === 'outside' && data.label && (
<span
style={{
@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import type { NodeData } from '@/types'
/**
* Type-level assertions for the collapse feature. Behavioral coverage lives
* in:
* - src/stores/__tests__/canvasStore.collapse.test.ts (store action)
* - src/utils/__tests__/collapseFilter.test.ts (BFS + edge rewire)
* - src/utils/__tests__/canvasSerializer.collapse.test.ts (round-trip)
*/
describe('NodeData.collapsed', () => {
it('accepts a boolean collapsed flag as a first-class field', () => {
const nodeData: NodeData = {
label: 'Test Zone',
type: 'groupRect',
status: 'online',
services: [],
collapsed: true,
}
expect(nodeData.collapsed).toBe(true)
})
it('treats a missing flag as expanded', () => {
const nodeData: NodeData = {
label: 'Test Zone',
type: 'groupRect',
status: 'online',
services: [],
}
expect(nodeData.collapsed).toBeUndefined()
})
})
+20 -1
View File
@@ -1,7 +1,8 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, 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'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -22,6 +23,8 @@ export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
export const LaptopNode = (props: N) => <BaseNode {...props} icon={Laptop} />
export const MobileNode = (props: N) => <BaseNode {...props} icon={Smartphone} />
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
@@ -30,3 +33,19 @@ export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
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, 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 { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -19,6 +27,8 @@ export const nodeTypes = {
camera: CameraNode,
printer: PrinterNode,
computer: ComputerNode,
laptop: LaptopNode,
mobile: MobileNode,
cpl: CplNode,
docker_host: DockerHostNode,
docker_container: DockerContainerNode,
@@ -29,4 +39,18 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode,
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,
}
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import {
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
Radio, Zap, Lightbulb,
type LucideIcon,
} from 'lucide-react'
@@ -21,17 +21,17 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
const EDITABLE_NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
'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> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
camera: Camera, printer: Printer, computer: Monitor, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
docker_host: Anchor, docker_container: Package,
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
generic: Circle,
@@ -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>
)
}
+24 -1
View File
@@ -18,7 +18,9 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
{ 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'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
@@ -512,6 +514,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span>
</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>
)}
@@ -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()
})
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 ─────────────────────────────────────────────────────────
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)
})
it('supports the full 1..48 range', () => {
it('supports the full 1..64 range (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('48')
fireEvent.change(slider, { target: { value: '48' } })
expect(slider.max).toBe('64')
fireEvent.change(slider, { target: { value: '52' } })
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 } })
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 ──────────────────────────────────────────────────────
+118 -2
View File
@@ -1,10 +1,14 @@
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, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { scanApi, settingsApi } from '@/api/client'
import { designsApi, scanApi, settingsApi } 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 { useLatestRelease } from '@/hooks/useLatestRelease'
import {
@@ -50,6 +54,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView)
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.
if (forceView !== prevForceView) {
@@ -88,6 +123,75 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<Logo size={28} showText={!collapsed} />
</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 */}
<nav className="flex flex-col gap-0.5 p-2">
<SidebarItem
@@ -190,6 +294,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
</div>
{!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>
)
}
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCanvasStore } from '../canvasStore'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
describe('canvasStore - toggleNodeCollapsed', () => {
it('toggles collapsed state on a zone node', () => {
const { result } = renderHook(() => useCanvasStore())
const node: Node<NodeData> = {
id: 'zone-1',
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
position: { x: 0, y: 0 },
}
act(() => {
result.current.addNode(node)
})
let zone = result.current.nodes.find((n) => n.id === 'zone-1')
expect(zone?.data.collapsed).toBeUndefined()
act(() => {
result.current.toggleNodeCollapsed('zone-1')
})
zone = result.current.nodes.find((n) => n.id === 'zone-1')
expect(zone?.data.collapsed).toBe(true)
act(() => {
result.current.toggleNodeCollapsed('zone-1')
})
zone = result.current.nodes.find((n) => n.id === 'zone-1')
expect(zone?.data.collapsed).toBe(false)
})
it('marks canvas as unsaved when toggling collapse', () => {
const { result } = renderHook(() => useCanvasStore())
const node: Node<NodeData> = {
id: 'zone-1',
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
position: { x: 0, y: 0 },
}
act(() => {
result.current.addNode(node)
result.current.markSaved()
})
expect(result.current.hasUnsavedChanges).toBe(false)
act(() => {
result.current.toggleNodeCollapsed('zone-1')
})
expect(result.current.hasUnsavedChanges).toBe(true)
})
})
@@ -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()
})
})
+11
View File
@@ -52,6 +52,7 @@ interface CanvasState {
setEditingGroupRectId: (id: string | null) => void
editingTextId: string | null
setEditingTextId: (id: string | null) => void
toggleNodeCollapsed: (id: string) => void
createGroup: (nodeIds: string[], name: string) => void
ungroup: (groupId: string) => void
markSaved: () => void
@@ -374,6 +375,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
setEditingTextId: (id) => set({ editingTextId: id }),
toggleNodeCollapsed: (id) =>
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === id
? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } }
: n
),
hasUnsavedChanges: true,
})),
createGroup: (nodeIds, name) =>
set((state) => {
const PADDING_H = 24
+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,
}
}),
}))
+2 -2
View File
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
describe('NODE_TYPE_LABELS', () => {
it('has an entry for every node type', () => {
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'laptop', 'mobile', 'generic']
expectedTypes.forEach((t) => {
expect(NODE_TYPE_LABELS).toHaveProperty(t)
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
describe('EDGE_TYPE_LABELS', () => {
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) => {
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
})
+56 -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 =
| 'isp'
| 'router'
@@ -13,6 +25,8 @@ export type NodeType =
| 'camera'
| 'printer'
| 'computer'
| 'laptop'
| 'mobile'
| 'cpl'
| 'docker_host'
| 'docker_container'
@@ -23,6 +37,20 @@ export type NodeType =
| 'zigbee_coordinator'
| 'zigbee_router'
| 'zigbee_enddevice'
| 'grid'
| 'ups'
| 'battery'
| 'generator'
| 'solar_panel'
| 'inverter'
| 'circuit_breaker'
| 'contactor'
| 'electrical_switch'
| 'socket'
| 'light'
| 'meter'
| 'transformer'
| 'load'
export type TextPosition =
| 'top-left'
@@ -35,7 +63,7 @@ export type TextPosition =
| 'bottom-center'
| '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'
@@ -97,9 +125,17 @@ export interface NodeData extends Record<string, unknown> {
width?: number
height?: number
}
/**
* Collapsible zone state (type === 'groupRect'). When true, the zone hides
* its descendants on the canvas. Persisted via `custom_colors.collapsed`
* round-trip for back-compat with older saves.
*/
collapsed?: boolean
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
/** Show a port number (1..N) above each bottom connection point. */
show_port_numbers?: boolean
/** Text node content (type === 'text') */
text_content?: string
}
@@ -137,6 +173,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
camera: 'Camera',
printer: 'Printer',
computer: 'Computer',
laptop: 'Laptop',
mobile: 'Phone / Mobile',
cpl: 'CPL / Powerline',
docker_host: 'Docker Host',
docker_container: 'Docker Container',
@@ -147,6 +185,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router',
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> = {
@@ -163,6 +215,8 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
vlan: 'VLAN',
virtual: 'Virtual',
cluster: 'Cluster',
fibre: 'Fibre',
electrical: 'Electrical Wire',
}
export interface NodeTypeStyle {
@@ -0,0 +1,151 @@
import { describe, it, expect } from 'vitest'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
import { serializeNode, deserializeApiNode, type ApiNode } from '@/utils/canvasSerializer'
/**
* Persistence contract for the collapse flag on groupRect nodes:
*
* 1. Serialize stashes `data.collapsed` into `custom_colors.collapsed`
* so the existing API blob shape can carry it without a schema change.
* 2. Deserialize hoists it back to the first-class `data.collapsed` field.
* 3. Legacy saves that already had `custom_colors.collapsed` (the original
* shape from PR #158 before the field was promoted) still load
* correctly.
*/
function makeGroupRectRfNode(collapsed?: boolean): Node<NodeData> {
return {
id: 'zone-1',
type: 'groupRect',
position: { x: 0, y: 0 },
data: {
label: 'Zigbee Mesh',
type: 'groupRect',
status: 'unknown',
services: [],
...(collapsed !== undefined ? { collapsed } : {}),
},
}
}
describe('canvasSerializer — groupRect collapse', () => {
it('stashes data.collapsed=true into custom_colors on serialize', () => {
const rf = makeGroupRectRfNode(true)
const api = serializeNode(rf) as Record<string, unknown>
const cc = api.custom_colors as Record<string, unknown>
expect(cc.collapsed).toBe(true)
})
it('writes collapsed=false when the flag is missing (explicit default)', () => {
const rf = makeGroupRectRfNode(undefined)
const api = serializeNode(rf) as Record<string, unknown>
const cc = api.custom_colors as Record<string, unknown>
expect(cc.collapsed).toBe(false)
})
it('hoists custom_colors.collapsed back to data.collapsed on deserialize', () => {
const apiNode: ApiNode = {
id: 'zone-1',
type: 'groupRect',
label: 'Zone',
pos_x: 0,
pos_y: 0,
status: 'unknown',
services: [],
custom_colors: { collapsed: true, width: 360, height: 240 },
}
const rf = deserializeApiNode(apiNode, new Map())
expect(rf.data.collapsed).toBe(true)
})
it('treats missing custom_colors.collapsed as false on deserialize', () => {
const apiNode: ApiNode = {
id: 'zone-1',
type: 'groupRect',
label: 'Zone',
pos_x: 0,
pos_y: 0,
status: 'unknown',
services: [],
custom_colors: { width: 360, height: 240 },
}
const rf = deserializeApiNode(apiNode, new Map())
expect(rf.data.collapsed).toBe(false)
})
it('round-trips the collapse flag through serialize → deserialize', () => {
const rf = makeGroupRectRfNode(true)
const api = serializeNode(rf) as unknown as ApiNode
const back = deserializeApiNode(api, new Map())
expect(back.data.collapsed).toBe(true)
})
})
describe('canvasSerializer — collapse on non-groupRect node types', () => {
it('stashes data.collapsed into custom_colors for a group container', () => {
const rf: Node<NodeData> = {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
data: {
label: 'Container',
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
collapsed: true,
},
}
const api = serializeNode(rf) as Record<string, unknown>
const cc = api.custom_colors as Record<string, unknown>
expect(cc.collapsed).toBe(true)
// Existing custom_colors keys are preserved alongside the stash.
expect(cc.show_border).toBe(true)
})
it('leaves custom_colors null when neither flag nor colors are set', () => {
const rf: Node<NodeData> = {
id: 's1',
type: 'server',
position: { x: 0, y: 0 },
data: { label: 'Server', type: 'server', status: 'online', services: [] },
}
const api = serializeNode(rf) as Record<string, unknown>
expect(api.custom_colors).toBeNull()
})
it('hoists custom_colors.collapsed to data.collapsed for a group container', () => {
const apiNode: ApiNode = {
id: 'g1',
type: 'group',
label: 'Container',
pos_x: 0,
pos_y: 0,
status: 'unknown',
services: [],
custom_colors: { show_border: true, collapsed: true },
}
const rf = deserializeApiNode(apiNode, new Map())
expect(rf.data.collapsed).toBe(true)
})
it('round-trips collapse on a group container', () => {
const rf: Node<NodeData> = {
id: 'g1',
type: 'group',
position: { x: 0, y: 0 },
data: {
label: 'Container',
type: 'group',
status: 'unknown',
services: [],
custom_colors: { show_border: true },
collapsed: true,
},
}
const api = serializeNode(rf) as unknown as ApiNode
const back = deserializeApiNode(api, new Map())
expect(back.data.collapsed).toBe(true)
})
})
@@ -0,0 +1,262 @@
import { describe, it, expect } from 'vitest'
import type { Edge, Node } from '@xyflow/react'
import {
getVisibleNodeIds,
rewireEdgesForCollapse,
getZoneSpatialChildren,
computeCollapseInfo,
} from '../collapseFilter'
import type { EdgeData, NodeData } from '@/types'
interface MkOpts {
parentId?: string
collapsed?: boolean
position?: { x: number; y: number }
width?: number
height?: number
type?: NodeData['type']
}
// Outside the default 360x240 zone bbox at origin — used by tests that need
// a node that must NOT be spatially captured by a collapsed zone.
const FAR = { x: 10000, y: 0 }
const mkNode = (id: string, opts: MkOpts = {}): Node<NodeData> => ({
id,
position: opts.position ?? { x: 0, y: 0 },
...(opts.width !== undefined ? { width: opts.width } : {}),
...(opts.height !== undefined ? { height: opts.height } : {}),
...(opts.parentId ? { parentId: opts.parentId } : {}),
data: {
label: id,
type: opts.type ?? (opts.parentId ? 'server' : 'groupRect'),
status: 'online',
services: [],
...(opts.collapsed !== undefined ? { collapsed: opts.collapsed } : {}),
},
})
const mkEdge = (id: string, source: string, target: string): Edge<EdgeData> => ({
id,
source,
target,
})
describe('getVisibleNodeIds — parentId cascade', () => {
it('returns all nodes when nothing is collapsed', () => {
const nodes = [
mkNode('zone'),
mkNode('child-a', { parentId: 'zone' }),
mkNode('child-b', { parentId: 'zone' }),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child-a', 'child-b']))
})
it('hides direct children of a collapsed parent but keeps the parent itself', () => {
const nodes = [
mkNode('zone', { collapsed: true }),
mkNode('child-a', { parentId: 'zone' }),
mkNode('child-b', { parentId: 'zone' }),
mkNode('outside', { position: FAR }),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'outside']))
})
it('hides the entire subtree when an ancestor is collapsed (multi-level)', () => {
const nodes = [
mkNode('root', { collapsed: true }),
mkNode('mid', { parentId: 'root', collapsed: false }),
mkNode('leaf', { parentId: 'mid' }),
]
const v = getVisibleNodeIds(nodes)
expect(v.has('root')).toBe(true)
expect(v.has('mid')).toBe(false)
expect(v.has('leaf')).toBe(false)
})
it('hides only the nested subtree when an inner zone is collapsed', () => {
const nodes = [
mkNode('root', { collapsed: false }),
mkNode('inner', { parentId: 'root', collapsed: true }),
mkNode('leaf', { parentId: 'inner' }),
mkNode('sibling', { parentId: 'root' }),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['root', 'inner', 'sibling']))
})
it('handles a zone with no children', () => {
expect(getVisibleNodeIds([mkNode('empty-zone', { collapsed: true })]))
.toEqual(new Set(['empty-zone']))
})
it('returns an empty set for empty input', () => {
expect(getVisibleNodeIds([])).toEqual(new Set())
})
it('treats nodes with no collapsed flag as expanded', () => {
const nodes = [mkNode('zone'), mkNode('child', { parentId: 'zone' })]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child']))
})
it('is independent of insertion order (children declared before parent)', () => {
const nodes = [
mkNode('child', { parentId: 'zone' }),
mkNode('zone', { collapsed: true }),
]
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone']))
})
})
describe('getZoneSpatialChildren', () => {
it('picks up top-level nodes whose centre lies inside the zone bbox', () => {
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
const inside = mkNode('inside', { position: { x: 100, y: 50 }, type: 'server' })
const outside = mkNode('outside', { position: { x: 500, y: 0 }, type: 'server' })
expect(getZoneSpatialChildren(zone, [zone, inside, outside])).toEqual(['inside'])
})
it('ignores the zone itself', () => {
const zone = mkNode('zone', { width: 400, height: 300 })
expect(getZoneSpatialChildren(zone, [zone])).toEqual([])
})
it('ignores nodes with a parentId (handled via parentId cascade)', () => {
const zone = mkNode('zone', { width: 400, height: 300 })
const child = mkNode('child', { parentId: 'other', type: 'server' })
expect(getZoneSpatialChildren(zone, [zone, child])).toEqual([])
})
it('uses fallback dimensions for nodes with no width/height set', () => {
const zone = mkNode('zone', { width: 400, height: 300 })
// No width/height → defaults (200, 80). Centre at (100, 40), inside.
const n = mkNode('n', { type: 'server' })
expect(getZoneSpatialChildren(zone, [zone, n])).toEqual(['n'])
})
})
describe('computeCollapseInfo — spatial collapse via groupRect zones', () => {
it('hides nodes spatially inside a collapsed zone and records hiddenBy', () => {
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' })
const outside = mkNode('outside', { position: FAR, type: 'server' })
const info = computeCollapseInfo([zone, inside, outside])
expect(info.visibleIds).toEqual(new Set(['zone', 'outside']))
expect(info.hiddenBy.get('inside')).toBe('zone')
})
it('cascades parentId descendants of spatially-hidden nodes', () => {
// Proxmox host sitting inside a collapsed zone — its VMs (parentId)
// must also be hidden even though they live at relative coords.
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
const px = mkNode('px', { position: { x: 50, y: 50 }, type: 'proxmox' })
const vm = mkNode('vm', { parentId: 'px', type: 'vm' })
const info = computeCollapseInfo([zone, px, vm])
expect(info.visibleIds).toEqual(new Set(['zone']))
expect(info.hiddenBy.get('vm')).toBe('zone')
})
it('a nested groupRect inside a collapsed outer zone is also hidden', () => {
const outer = mkNode('outer', { collapsed: true, width: 600, height: 400 })
const inner = mkNode('inner', { position: { x: 100, y: 100 }, width: 200, height: 150 })
const leaf = mkNode('leaf', { position: { x: 150, y: 150 }, type: 'server' })
const info = computeCollapseInfo([outer, inner, leaf])
expect(info.visibleIds).toEqual(new Set(['outer']))
})
it('does not affect nodes outside every collapsed zone', () => {
const a = mkNode('a', { collapsed: true, width: 300, height: 200 })
const b = mkNode('b', { position: { x: 1000, y: 1000 }, width: 300, height: 200 })
const free = mkNode('free', { position: { x: 2000, y: 2000 }, type: 'server' })
const info = computeCollapseInfo([a, b, free])
expect(info.visibleIds.has('free')).toBe(true)
})
})
describe('rewireEdgesForCollapse', () => {
it('keeps edges between two visible nodes unchanged (same reference)', () => {
const nodes = [mkNode('a'), mkNode('b', { position: FAR })]
const edges = [mkEdge('e1', 'a', 'b')]
const info = computeCollapseInfo(nodes)
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
expect(out).toHaveLength(1)
expect(out[0]).toBe(edges[0])
})
it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => {
const nodes = [
mkNode('zone', { collapsed: true }),
mkNode('leaf', { parentId: 'zone' }),
mkNode('outside', { position: FAR }),
]
const info = computeCollapseInfo(nodes)
const edges = [mkEdge('e1', 'outside', 'leaf')]
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
expect(out[0].source).toBe('outside')
expect(out[0].target).toBe('zone')
expect(out[0].sourceHandle).toBeNull()
expect(out[0].targetHandle).toBeNull()
})
it('reroutes a cross-boundary edge to a collapsed groupRect zone (spatial)', () => {
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' })
const outside = mkNode('outside', { position: FAR, type: 'server' })
const nodes = [zone, inside, outside]
const info = computeCollapseInfo(nodes)
const edges = [mkEdge('e1', 'outside', 'inside')]
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
expect(out[0].source).toBe('outside')
expect(out[0].target).toBe('zone')
})
it('drops an edge between two siblings inside the same collapsed zone (self-loop)', () => {
const nodes = [
mkNode('zone', { collapsed: true }),
mkNode('a', { parentId: 'zone' }),
mkNode('b', { parentId: 'zone' }),
]
const info = computeCollapseInfo(nodes)
expect(rewireEdgesForCollapse([mkEdge('e1', 'a', 'b')], nodes, info.visibleIds, info.hiddenBy))
.toEqual([])
})
it('de-dupes parallel cross-boundary edges that rewire to the same pair', () => {
const nodes = [
mkNode('zone', { collapsed: true }),
mkNode('coord', { position: FAR }),
...Array.from({ length: 5 }, (_, i) => mkNode(`leaf-${i}`, { parentId: 'zone' })),
]
const info = computeCollapseInfo(nodes)
const edges = Array.from({ length: 5 }, (_, i) => mkEdge(`e-${i}`, 'coord', `leaf-${i}`))
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
expect(out).toHaveLength(1)
expect(out[0].source).toBe('coord')
expect(out[0].target).toBe('zone')
})
it('walks the chain to the nearest visible ancestor (nested collapse)', () => {
const nodes = [
mkNode('root', { collapsed: true }),
mkNode('mid', { parentId: 'root' }),
mkNode('leaf', { parentId: 'mid' }),
mkNode('outside', { position: FAR }),
]
const info = computeCollapseInfo(nodes)
const out = rewireEdgesForCollapse(
[mkEdge('e1', 'outside', 'leaf')],
nodes,
info.visibleIds,
info.hiddenBy,
)
expect(out[0].target).toBe('root')
})
it('drops an edge whose endpoint has no visible ancestor', () => {
const edges = [mkEdge('e1', 'ghost', 'also-ghost')]
expect(rewireEdgesForCollapse(edges, [], new Set(), new Map())).toEqual([])
})
it('returns an empty array for empty input', () => {
expect(rewireEdgesForCollapse([], [], new Set(), new Map())).toEqual([])
})
})
@@ -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 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', () => {
it('has an entry for every EdgeType', () => {
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
it('cluster default is proxmox orange', () => {
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')
})
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', () => {
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCanvasStore } from '../../stores/canvasStore'
import { computeCollapseInfo } from '../collapseFilter'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
const mk = (id: string, type: NodeData['type'] = 'server'): Node<NodeData> => ({
id,
type,
position: { x: 100, y: 100 },
data: { label: id, type, status: 'online', services: [] },
})
describe('integration — createGroup + toggleNodeCollapsed hides children', () => {
it('hides parentId children of a collapsed group container', () => {
const { result } = renderHook(() => useCanvasStore())
act(() => {
result.current.addNode(mk('c1'))
result.current.addNode(mk('c2'))
result.current.createGroup(['c1', 'c2'], 'My Group')
})
// Find the auto-generated group id.
const grp = result.current.nodes.find((n) => n.type === 'group')!
expect(grp).toBeDefined()
expect(result.current.nodes.find((n) => n.id === 'c1')!.parentId).toBe(grp.id)
// Pre-collapse: all visible.
let info = computeCollapseInfo(result.current.nodes)
expect(info.visibleIds.has('c1')).toBe(true)
expect(info.visibleIds.has('c2')).toBe(true)
// Collapse the group via the store action.
act(() => result.current.toggleNodeCollapsed(grp.id))
expect(result.current.nodes.find((n) => n.id === grp.id)!.data.collapsed).toBe(true)
info = computeCollapseInfo(result.current.nodes)
expect(info.visibleIds.has(grp.id)).toBe(true)
expect(info.visibleIds.has('c1')).toBe(false)
expect(info.visibleIds.has('c2')).toBe(false)
expect(info.hiddenBy.get('c1')).toBe(grp.id)
})
})
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
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', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
})
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24)
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')
})
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', () => {
const yaml = `
- nodeType: proxmox
+2 -2
View File
@@ -4,9 +4,9 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
const NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', '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']
describe('THEME_ORDER', () => {
+25 -3
View File
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
width?: number | null
height?: number | null
bottom_handles?: number
show_port_numbers?: boolean
}
export interface ApiEdge {
@@ -76,6 +77,9 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
...n.data.custom_colors,
width: n.measured?.width ?? n.width ?? 360,
height: n.measured?.height ?? n.height ?? 240,
// Stash collapse state inside custom_colors so the API/YAML blob does
// not need a new column. Hoisted back to `data.collapsed` on load.
collapsed: n.data.collapsed ?? false,
},
}
}
@@ -94,7 +98,13 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
notes: n.data.notes ?? null,
parent_id: n.data.parent_id ?? null,
container_mode: n.data.container_mode ?? false,
custom_colors: n.data.custom_colors ?? null,
// Stash collapse state inside the custom_colors blob so the backend's
// dict[str, Any] column carries it without a schema change. Hoisted
// back to `data.collapsed` on load. Applies to every node type — group
// containers, Proxmox hosts, etc. — not just groupRect zones.
custom_colors: n.data.collapsed !== undefined
? { ...(n.data.custom_colors ?? {}), collapsed: n.data.collapsed }
: (n.data.custom_colors ?? null),
custom_icon: n.data.custom_icon ?? null,
cpu_count: n.data.cpu_count ?? null,
cpu_model: n.data.cpu_model ?? null,
@@ -105,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x,
pos_y: n.position.y,
}
@@ -139,11 +150,15 @@ export function deserializeApiNode(
const w = (n.custom_colors?.width as number | undefined) ?? 360
const h = (n.custom_colors?.height as number | undefined) ?? 240
const z = (n.custom_colors?.z_order as number | undefined) ?? 1
// Hoist persisted collapse flag from the custom_colors stash to a
// first-class field on NodeData. Tolerates legacy saves that already had
// it there from before the type was promoted.
const collapsed = Boolean(n.custom_colors?.collapsed)
return {
id: n.id,
type: 'groupRect',
position: { x: n.pos_x, y: n.pos_y },
data: n as unknown as NodeData,
data: { ...(n as unknown as NodeData), collapsed },
width: w,
height: h,
zIndex: z - 10,
@@ -155,7 +170,14 @@ export function deserializeApiNode(
id: n.id,
type: normalizedType,
position: { x: n.pos_x, y: n.pos_y },
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
// Hoist persisted collapse flag from the custom_colors stash (matches
// the symmetric serialize step). Applies to every node type.
data: {
...n,
type: normalizedType,
bottom_handles: clampBottomHandles(n.bottom_handles ?? 1),
collapsed: Boolean(n.custom_colors?.collapsed),
} as unknown as NodeData,
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
? { width: n.width ?? 300, height: n.height ?? 200 }
+196
View File
@@ -0,0 +1,196 @@
import type { Edge, Node } from '@xyflow/react'
import type { EdgeData, NodeData } from '@/types'
/**
* Collapse model
* ──────────────
* Two ways a node can collapse and hide what it "contains":
*
* 1. parentId hierarchy — `type: 'group'` containers (createGroup) and
* Proxmox container_mode children. Setting `data.collapsed = true` on
* such a node hides every node in its parentId subtree.
*
* 2. Spatial containment — `type: 'groupRect'` decorative zones drawn
* around nodes. Zones do not parent their contents in React Flow, so
* we hit-test every top-level node's centre against the zone bbox to
* decide what is "inside". Collapsing a zone hides every node whose
* centre lies inside the zone (plus the parentId subtrees of those
* nodes, so e.g. a Proxmox host inside a collapsed zone also takes its
* VMs/LXCs with it).
*
* `hiddenBy` records which collapsed ancestor hid each node — used by edge
* rewiring to redirect a vanished endpoint to the visible zone the user is
* actually looking at.
*/
interface BBox { x: number; y: number; w: number; h: number }
const DEFAULT_NODE_W = 200
const DEFAULT_NODE_H = 80
const DEFAULT_ZONE_W = 360
const DEFAULT_ZONE_H = 240
function bboxOf(n: Node<NodeData>, fallbackW: number, fallbackH: number): BBox {
return {
x: n.position.x,
y: n.position.y,
w: n.width ?? fallbackW,
h: n.height ?? fallbackH,
}
}
function centerInside(n: Node<NodeData>, b: BBox): boolean {
const w = n.width ?? DEFAULT_NODE_W
const h = n.height ?? DEFAULT_NODE_H
const cx = n.position.x + w / 2
const cy = n.position.y + h / 2
return cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h
}
/**
* Node ids whose centre lies inside the given zone, excluding the zone
* itself and any node that is a React Flow child (parentId set — those are
* positioned relative to their parent, not in absolute canvas coordinates).
*/
export function getZoneSpatialChildren(
zone: Node<NodeData>,
nodes: Node<NodeData>[],
): string[] {
const zb = bboxOf(zone, DEFAULT_ZONE_W, DEFAULT_ZONE_H)
const out: string[] = []
for (const n of nodes) {
if (n.id === zone.id) continue
if (n.parentId) continue
if (centerInside(n, zb)) out.push(n.id)
}
return out
}
function buildChildrenByParent(nodes: Node<NodeData>[]): Map<string, string[]> {
const m = new Map<string, string[]>()
for (const n of nodes) {
if (!n.parentId) continue
const arr = m.get(n.parentId)
if (arr) arr.push(n.id)
else m.set(n.parentId, [n.id])
}
return m
}
export interface CollapseInfo {
/** Ids the canvas should render. */
visibleIds: Set<string>
/** For each hidden id, the id of the collapsed ancestor that hid it. */
hiddenBy: Map<string, string>
}
/**
* Single source of truth for visibility under collapse. O(n) over nodes
* (the spatial pass is O(z·n) where z is the number of collapsed zones).
*/
export function computeCollapseInfo(nodes: Node<NodeData>[]): CollapseInfo {
const childrenByParent = buildChildrenByParent(nodes)
const hidden = new Set<string>()
const hiddenBy = new Map<string, string>()
const hideSubtree = (rootId: string, hider: string) => {
const queue = [...(childrenByParent.get(rootId) ?? [])]
while (queue.length > 0) {
const id = queue.shift()!
if (hidden.has(id)) continue
hidden.add(id)
if (!hiddenBy.has(id)) hiddenBy.set(id, hider)
const sub = childrenByParent.get(id)
if (sub) queue.push(...sub)
}
}
// Pass 1 — parentId-based collapse (real containers).
for (const n of nodes) {
if (n.data.collapsed) hideSubtree(n.id, n.id)
}
// Pass 2 — spatial collapse (groupRect zones).
for (const n of nodes) {
if (n.data.type !== 'groupRect') continue
if (!n.data.collapsed) continue
const contained = getZoneSpatialChildren(n, nodes)
for (const id of contained) {
if (!hidden.has(id)) {
hidden.add(id)
if (!hiddenBy.has(id)) hiddenBy.set(id, n.id)
}
hideSubtree(id, n.id)
}
}
const visibleIds = new Set<string>()
for (const n of nodes) {
if (!hidden.has(n.id)) visibleIds.add(n.id)
}
return { visibleIds, hiddenBy }
}
/**
* Convenience wrapper kept for call sites that only need the visible set.
*/
export function getVisibleNodeIds(nodes: Node<NodeData>[]): Set<string> {
return computeCollapseInfo(nodes).visibleIds
}
/**
* Rewire edges so that any endpoint inside a collapsed subtree (parentId or
* spatial) is replaced with the nearest visible ancestor. See module
* docstring for the full rationale.
*
* - Both endpoints visible → edge kept as-is.
* - One endpoint hidden → endpoint replaced by its nearest
* visible ancestor; edge surfaces
* as a stub on the collapsed zone.
* - Both endpoints hidden under the
* same visible ancestor → dropped (would be a self-loop).
* - Parallel rewires to the same pair → de-duplicated; one stub kept.
* (Prevents a 20-device mesh from rendering 20 stacked stubs.)
* - Endpoint with no visible ancestor → dropped.
*/
export function rewireEdgesForCollapse(
edges: Edge<EdgeData>[],
nodes: Node<NodeData>[],
visibleIds: Set<string>,
hiddenBy?: Map<string, string>,
): Edge<EdgeData>[] {
// If the caller already computed hiddenBy (CanvasContainer path), reuse
// it. Otherwise recompute — keeps the helper callable from tests without
// forcing them to thread the second map through.
const hb = hiddenBy ?? computeCollapseInfo(nodes).hiddenBy
const nearestVisible = (id: string): string | null => {
let cur: string | undefined = id
const guard = new Set<string>()
while (cur !== undefined) {
if (visibleIds.has(cur)) return cur
if (guard.has(cur)) return null
guard.add(cur)
cur = hb.get(cur)
}
return null
}
const seen = new Set<string>()
const out: Edge<EdgeData>[] = []
for (const e of edges) {
const src = nearestVisible(e.source)
const tgt = nearestVisible(e.target)
if (src === null || tgt === null) continue
if (src === tgt) continue
const key = `${src}->${tgt}`
if (seen.has(key)) continue
seen.add(key)
if (src === e.source && tgt === e.target) {
out.push(e)
} else {
out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null })
}
}
return out
}
+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',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
electrical: '#e3b341',
}
+3 -3
View File
@@ -2,14 +2,14 @@
* Bottom handle configuration for multi-handle nodes.
*
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
*
* 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 MAX_BOTTOM_HANDLES = 48
export const MAX_BOTTOM_HANDLES = 64
/** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string {
+40 -21
View File
@@ -24,7 +24,9 @@ import {
// Communications
Mail, MessageSquare, Phone,
// Misc devices
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
// Electrical
ToggleLeft,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
@@ -50,6 +52,7 @@ export const ICON_REGISTRY: IconEntry[] = [
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
{ key: 'laptop', label: 'Laptop', category: 'Infrastructure', icon: Laptop },
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
@@ -153,30 +156,46 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
)
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe,
router: Router,
firewall: Flame,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
cpl: PlugZap,
docker_host: Anchor,
docker_container: Package,
isp: Globe,
router: Router,
firewall: Flame,
switch: Network,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
camera: Cctv,
printer: Printer,
computer: Monitor,
laptop: Laptop,
mobile: Smartphone,
cpl: PlugZap,
docker_host: Anchor,
docker_container: Package,
zigbee_coordinator: Radio,
zigbee_router: Zap,
zigbee_enddevice: Lightbulb,
generic: Circle,
group: Circle,
groupRect: Circle,
text: Type,
group: Circle,
groupRect: Circle,
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.
+117 -9
View File
@@ -56,6 +56,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
laptop: { border: '#a855f7', icon: '#a855f7' },
mobile: { border: '#ec4899', icon: '#ec4899' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
@@ -66,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
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',
nodeIconBackground: '#161b22',
@@ -84,6 +100,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
electrical:'#e3b341',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
@@ -117,6 +135,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#94a3b8', icon: '#94a3b8' },
printer: { border: '#94a3b8', icon: '#94a3b8' },
computer: { border: '#c084fc', icon: '#c084fc' },
laptop: { border: '#c084fc', icon: '#c084fc' },
mobile: { border: '#ec4899', icon: '#ec4899' },
cpl: { border: '#fbbf24', icon: '#fbbf24' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
@@ -127,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' },
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',
nodeIconBackground: '#111111',
@@ -145,15 +179,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#22d3ee',
virtual: '#6b7280',
cluster: '#fb923c',
fibre: '#06b6d4',
electrical:'#e3b341',
},
edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111',
edgeLabelColor: '#666666',
edgeLabelBorder: '#1c1c1e',
canvasBackground: '#000000',
canvasDotColor: '#1a1a1a',
handleBackground: '#1c1c1e',
handleBorder: '#444444',
edgeLabelBackground:'#161b22',
edgeLabelColor: '#9ca3af',
edgeLabelBorder: '#374151',
canvasBackground: '#030712',
canvasDotColor: '#374151',
handleBackground: '#374151',
handleBorder: '#9ca3af',
reactFlowColorMode: 'dark',
},
},
@@ -178,6 +214,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#6b7280', icon: '#6b7280' },
printer: { border: '#6b7280', icon: '#6b7280' },
computer: { border: '#7c3aed', icon: '#7c3aed' },
laptop: { border: '#7c3aed', icon: '#7c3aed' },
mobile: { border: '#db2777', icon: '#db2777' },
cpl: { border: '#b45309', icon: '#b45309' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0369a1', icon: '#0369a1' },
@@ -188,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' },
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',
nodeIconBackground: '#f0f6ff',
@@ -206,15 +258,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#0284c7',
virtual: '#9ca3af',
cluster: '#ea580c',
fibre: '#0891b2',
electrical:'#d97706',
},
edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a',
edgeLabelColor: '#6b7280',
edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de',
handleBorder: '#9ca3af',
handleBorder: '#6b7280',
reactFlowColorMode: 'light',
},
},
@@ -239,6 +293,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#8888ff', icon: '#8888ff' },
printer: { border: '#8888ff', icon: '#8888ff' },
computer: { border: '#ff00ff', icon: '#ff00ff' },
laptop: { border: '#ff00ff', icon: '#ff00ff' },
mobile: { border: '#ff3399', icon: '#ff3399' },
cpl: { border: '#ffff00', icon: '#ffff00' },
docker_host: { border: '#00aaff', icon: '#00aaff' },
docker_container: { border: '#00ddff', icon: '#00ddff' },
@@ -249,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' },
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',
nodeIconBackground: '#0a0a1a',
@@ -267,6 +337,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00ffff',
virtual: '#8888cc',
cluster: '#ff8800',
fibre: '#00e5ff',
electrical:'#ffff00',
},
edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a',
@@ -300,6 +372,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#005500', icon: '#005500' },
printer: { border: '#005500', icon: '#005500' },
computer: { border: '#008822', icon: '#008822' },
laptop: { border: '#008822', icon: '#008822' },
mobile: { border: '#00cc66', icon: '#00cc66' },
cpl: { border: '#66ff33', icon: '#66ff33' },
docker_host: { border: '#00cc88', icon: '#00cc88' },
docker_container: { border: '#00aacc', icon: '#00aacc' },
@@ -310,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', 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',
nodeIconBackground: '#002200',
@@ -328,6 +416,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00cc33',
virtual: '#004400',
cluster: '#33ff66',
fibre: '#00ffcc',
electrical:'#66ff33',
},
edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100',
@@ -361,6 +451,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
camera: { border: '#8b949e', icon: '#8b949e' },
printer: { border: '#8b949e', icon: '#8b949e' },
computer: { border: '#a855f7', icon: '#a855f7' },
laptop: { border: '#a855f7', icon: '#a855f7' },
mobile: { border: '#ec4899', icon: '#ec4899' },
cpl: { border: '#e3b341', icon: '#e3b341' },
docker_host: { border: '#2496ED', icon: '#2496ED' },
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
@@ -371,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' },
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',
nodeIconBackground: '#161b22',
@@ -389,6 +495,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
electrical: '#e3b341',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
+164
View File
@@ -0,0 +1,164 @@
#!/bin/bash
# Install / enable the Homelable MCP server as a systemd service.
#
# Run interactively as root, inside an LXC or any Debian/Ubuntu host.
# Typical Proxmox VE flow: create the LXC via the community-scripts/ProxmoxVE
# helper, then run this script inside that LXC.
#
# Idempotent: re-running is safe. If mcp/.env already exists, the script
# keeps it untouched and only refreshes the venv + systemd unit.
#
# Optional env vars (override defaults / skip the matching prompt):
# INSTALL_DIR repo root (default: /opt/homelable)
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
# REPO_REF branch/tag/commit when cloning (default: main)
# SERVICE_USER systemd User= (default: homelable)
# MCP_PORT listen port (default: 8001)
# MCP_API_KEY client → MCP key (default: prompt, auto-gen on empty)
# MCP_SERVICE_KEY MCP → backend key (default: prompt, auto-gen on empty; must match backend .env)
# BACKEND_URL backend base URL (default: http://127.0.0.1:8000)
set -euo pipefail
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
REPO_REF="${REPO_REF:-main}"
SERVICE_USER="${SERVICE_USER:-homelable}"
SERVICE_NAME="homelable-mcp"
MCP_PORT="${MCP_PORT:-8001}"
DEFAULT_BACKEND_URL="http://127.0.0.1:8000"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
log "Installing OS dependencies (git, python3-venv, curl)"
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
git python3 python3-venv python3-pip curl iproute2 >/dev/null
MCP_DIR="$INSTALL_DIR/mcp"
if [[ ! -d "$MCP_DIR" ]]; then
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
mkdir -p "$(dirname "$INSTALL_DIR")"
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
fi
[[ -f "$MCP_DIR/requirements.txt" ]] || fail "Missing $MCP_DIR/requirements.txt — repo layout unexpected."
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${MCP_PORT}$"; then
warn "Port $MCP_PORT already in use. If it's a previous $SERVICE_NAME instance this is fine; otherwise abort and free the port."
fi
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating service user '$SERVICE_USER'"
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
ENV_FILE="$MCP_DIR/.env"
gen_key() { python3 -c "import secrets;print('$1' + secrets.token_hex(24))"; }
if [[ -f "$ENV_FILE" ]]; then
log ".env already present at $ENV_FILE — keeping existing values"
else
[[ -f "$MCP_DIR/.env.example" ]] || fail "Missing $MCP_DIR/.env.example"
log "No .env found — generating one (press Enter to accept defaults)"
api_key="${MCP_API_KEY:-}"
svc_key="${MCP_SERVICE_KEY:-}"
backend_url="${BACKEND_URL:-}"
if [[ -z "$api_key" ]]; then
default_api_key="$(gen_key mcp_sk_)"
read -rp "MCP_API_KEY (client → MCP) [default: auto-generate]: " api_key
api_key="${api_key:-$default_api_key}"
fi
if [[ -z "$svc_key" ]]; then
default_svc_key="$(gen_key svc_)"
read -rp "MCP_SERVICE_KEY (MCP → backend, must match backend .env) [default: auto-generate]: " svc_key
svc_key="${svc_key:-$default_svc_key}"
fi
if [[ -z "$backend_url" ]]; then
read -rp "BACKEND_URL [$DEFAULT_BACKEND_URL]: " backend_url
backend_url="${backend_url:-$DEFAULT_BACKEND_URL}"
fi
umask 077
cat >"$ENV_FILE" <<EOF
MCP_API_KEY=$api_key
MCP_SERVICE_KEY=$svc_key
BACKEND_URL=$backend_url
EOF
log "Wrote $ENV_FILE (mode 600)"
warn "If the backend runs elsewhere, set the SAME MCP_SERVICE_KEY in its .env."
fi
VENV="$MCP_DIR/.venv"
if [[ ! -d "$VENV" ]]; then
log "Creating venv at $VENV"
python3 -m venv "$VENV"
fi
log "Installing Python deps"
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet -r "$MCP_DIR/requirements.txt"
chown -R "$SERVICE_USER":"$SERVICE_USER" "$MCP_DIR"
chmod 600 "$ENV_FILE"
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
log "Writing $UNIT"
cat >"$UNIT" <<EOF
[Unit]
Description=Homelable MCP server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$MCP_DIR
EnvironmentFile=$ENV_FILE
ExecStart=$VENV/bin/uvicorn app.main:app --host 0.0.0.0 --port $MCP_PORT
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now "$SERVICE_NAME"
systemctl restart "$SERVICE_NAME"
log "Waiting for MCP to come up on :$MCP_PORT"
ok=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if curl -fsS "http://127.0.0.1:${MCP_PORT}/health" >/dev/null 2>&1; then
ok=1; break
fi
sleep 1
done
if [[ "$ok" -ne 1 ]]; then
warn "MCP did not respond on /health within 10s. Check: journalctl -u $SERVICE_NAME -n 50"
else
log "MCP server is up."
fi
LXC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
API_KEY_VALUE="$(grep -E '^MCP_API_KEY=' "$ENV_FILE" | cut -d= -f2-)"
cat <<EOF
----------------------------------------------------------------
MCP server installed.
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
Listen: http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp
Env file: $ENV_FILE
Logs: journalctl -u $SERVICE_NAME -f
Claude Code client setup:
claude mcp add --transport sse homelable http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp \\
--header "X-API-Key: $API_KEY_VALUE"
----------------------------------------------------------------
EOF