Compare commits

..

29 Commits

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

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

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

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

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

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

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

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

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

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

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

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

ha-relevant: yes
2026-05-31 01:54:24 +02:00
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
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
50 changed files with 1788 additions and 127 deletions
+1 -1
View File
@@ -1 +1 @@
2.1.1
2.3.0
+35 -2
View File
@@ -20,6 +20,34 @@ from app.services.zigbee_service import build_zigbee_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
"""Build a NodeProperty list carrying a device MAC address.
Shape matches the frontend ``NodeProperty`` type
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
showing it on the canvas card from the right panel. Returns an empty list
when no MAC is known.
"""
if not mac:
return []
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
def merge_mac_property(
props: list[dict[str, Any]] | None, mac: str | None
) -> list[dict[str, Any]]:
"""Append a MAC NodeProperty to ``props`` unless one is already present.
Preserves any user-supplied properties (and an existing MAC row's
visibility) untouched. Used on approve so the scanned MAC is not lost.
"""
out = [dict(p) for p in (props or [])]
if not mac or any(p.get("key") == "MAC" for p in out):
return out
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
return out
class BulkActionRequest(BaseModel):
device_ids: list[str]
@@ -134,13 +162,14 @@ async def bulk_approve_devices(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
ip=device.ip,
mac=device.mac,
hostname=device.hostname,
status="online" if is_zigbee else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi
) if is_zigbee else [],
) if is_zigbee else build_mac_property(device.mac),
# 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),
@@ -234,17 +263,21 @@ async def approve_device(
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
_is_zigbee = node_data.type in _ZIGBEE_TYPES
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname,
status="online" if _is_zigbee else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi
) if _is_zigbee else (node_data.properties or []),
) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target,
)
+7 -1
View File
@@ -223,7 +223,13 @@ async def _persist_pending_import(
pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None:
pending.lqi = n.get("lqi")
if pending.status == "hidden":
if pending.status == "approved":
# The device was approved earlier but its canvas Node no longer
# exists (no Node matched the IEEE above) — it was deleted. Revive
# the row to "pending" so it reappears in the Pending list on
# re-import instead of being silently swallowed. (Issue #167)
pending.status = "pending"
elif pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields.
pass
pending_updated += 1
+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
+138
View File
@@ -698,6 +698,144 @@ async def test_bulk_approve_zigbee_populates_properties(
assert node.check_method == "none"
# --- MAC address propagation on approve (issue #168) ---
def test_build_mac_property_returns_hidden_row():
from app.api.routes.scan import build_mac_property
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
def test_build_mac_property_empty_when_no_mac():
from app.api.routes.scan import build_mac_property
assert build_mac_property(None) == []
assert build_mac_property("") == []
def test_merge_mac_property_appends_when_absent():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
# Existing prop preserved untouched.
assert existing[0] in merged
def test_merge_mac_property_idempotent_and_preserves_visibility():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
# No duplicate MAC row; user's visible=True choice kept.
macs = [p for p in merged if p["key"] == "MAC"]
assert len(macs) == 1
assert macs[0]["visible"] is True
def test_merge_mac_property_noop_without_mac():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
assert merge_mac_property(existing, None) == existing
@pytest.mark.asyncio
async def test_approve_device_copies_mac_to_node_and_properties(
client: AsyncClient, headers, pending_device, db_session
):
"""Approving a scanned device must carry its MAC onto the node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
# Payload intentionally omits mac — it must come from the pending device.
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
assert node.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_approve_device_does_not_duplicate_mac_property(
client: AsyncClient, headers, pending_device, db_session
):
"""If the approve payload already carries a MAC prop, don't add a second one."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"status": "unknown",
"services": [],
"properties": [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
],
},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert len(mac_props) == 1
# User's visibility choice is preserved.
assert mac_props[0]["visible"] is True
@pytest.mark.asyncio
async def test_bulk_approve_copies_mac_to_node_and_properties(
client: AsyncClient, headers, db_session
):
"""Bulk approve must also propagate the scanned MAC to node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
hostname="host-mac",
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
).scalar_one()
assert node.mac == "11:22:33:44:55:66"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
+86
View File
@@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
assert all(p["visible"] is False for p in refreshed.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_revives_orphaned_approved_device(
db_session,
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again — instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
model="CC2530",
vendor="TI",
lqi=220,
status="approved",
discovery_source="zigbee",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
# End device 0xE1 is brand new → created as pending; router was updated.
assert result.pending_created == 1
assert result.pending_updated == 1
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
@pytest.mark.asyncio
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
db_session,
) -> None:
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="hidden",
discovery_source="zigbee",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
"""If user has already made props visible, re-import must not flip them back."""
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.1.1",
"version": "2.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "2.1.1",
"version": "2.3.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.1.1",
"version": "2.3.0",
"type": "module",
"scripts": {
"dev": "vite",
+16 -3
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,6 +28,7 @@ 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, CustomStyleDef } from '@/types'
@@ -108,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]">
@@ -136,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,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,5 @@ export const edgeTypes = {
vlan: HomelableEdge,
virtual: HomelableEdge,
cluster: HomelableEdge,
fibre: HomelableEdge,
}
@@ -323,6 +323,7 @@ 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)` },
}
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()
})
})
@@ -1,7 +1,7 @@
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,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types'
@@ -22,6 +22,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} />
@@ -1,4 +1,4 @@
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 } from './index'
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode'
@@ -19,6 +19,8 @@ export const nodeTypes = {
camera: CameraNode,
printer: PrinterNode,
computer: ComputerNode,
laptop: LaptopNode,
mobile: MobileNode,
cpl: CplNode,
docker_host: DockerHostNode,
docker_container: DockerContainerNode,
@@ -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']
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,
+23 -1
View File
@@ -18,7 +18,8 @@ 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: 'Generic', types: ['generic', 'groupRect'] },
]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
@@ -512,6 +513,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>
)}
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildMacProperty } from '@/utils/macProperty'
interface PendingDevicesModalProps {
open: boolean
@@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type)
const properties = zigbee ? buildZigbeeProperties(device) : []
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
const nodeData = {
label: fallbackLabel,
type,
ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined,
status: zigbee ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[],
@@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
label: deviceLabel(d),
type,
ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined,
status: zigbee ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[],
properties: zigbee ? buildZigbeeProperties(d) : [],
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
},
})
})
@@ -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 ──────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ const mockApprove = vi.fn()
const mockHide = vi.fn()
const mockPending = vi.fn()
const mockHidden = vi.fn()
const mockAddNode = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
@@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
addNode: vi.fn(),
addNode: mockAddNode,
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
// setState is used by injectAutoEdges
@@ -174,6 +175,34 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
})
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockAddNode).toHaveBeenCalledTimes(2))
// dev-a is an IP device with a MAC → node carries mac + a MAC property row.
const ipNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n1')
expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01')
expect(ipNode.data.properties).toContainEqual({
key: 'MAC',
value: 'aa:bb:cc:dd:ee:01',
icon: null,
visible: false,
})
// dev-b is zigbee with no MAC → no MAC property row.
const zbNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n2')
expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false)
})
it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -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)
})
})
+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
+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)
})
+15 -2
View File
@@ -13,6 +13,8 @@ export type NodeType =
| 'camera'
| 'printer'
| 'computer'
| 'laptop'
| 'mobile'
| 'cpl'
| 'docker_host'
| 'docker_container'
@@ -35,7 +37,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'
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -97,9 +99,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 +147,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',
@@ -163,6 +175,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
vlan: 'VLAN',
virtual: 'Virtual',
cluster: 'Cluster',
fibre: 'Fibre',
}
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([])
})
})
@@ -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
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { buildMacProperty } from '../macProperty'
describe('buildMacProperty', () => {
it('returns a hidden MAC property row for a MAC', () => {
expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([
{ key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false },
])
})
it('returns an empty array when MAC is null/undefined/empty', () => {
expect(buildMacProperty(null)).toEqual([])
expect(buildMacProperty(undefined)).toEqual([])
expect(buildMacProperty('')).toEqual([])
})
})
+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
}
+1
View File
@@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
}
+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 N1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
* index N1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
*
* Invisible target handles follow the same pattern with a '-t' suffix:
* '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 {
+9
View File
@@ -0,0 +1,9 @@
import type { NodeProperty } from '@/types'
/** Build the MAC address property row shown in the right panel.
* Hidden by default the user opts in to showing it on the canvas card.
* Matches backend `build_mac_property`. Returns an empty array when no MAC. */
export function buildMacProperty(mac?: string | null): NodeProperty[] {
if (!mac) return []
return [{ key: 'MAC', value: mac, icon: null, visible: false }]
}
+4 -1
View File
@@ -24,7 +24,7 @@ import {
// Communications
Mail, MessageSquare, Phone,
// Misc devices
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
@@ -50,6 +50,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 },
@@ -167,6 +168,8 @@ export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
camera: Cctv,
printer: Printer,
computer: Monitor,
laptop: Laptop,
mobile: Smartphone,
cpl: PlugZap,
docker_host: Anchor,
docker_container: Package,
+18
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' },
@@ -84,6 +86,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
@@ -117,6 +120,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' },
@@ -145,6 +150,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#22d3ee',
virtual: '#6b7280',
cluster: '#fb923c',
fibre: '#06b6d4',
},
edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111',
@@ -178,6 +184,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' },
@@ -206,6 +214,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#0284c7',
virtual: '#9ca3af',
cluster: '#ea580c',
fibre: '#0891b2',
},
edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff',
@@ -239,6 +248,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' },
@@ -267,6 +278,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00ffff',
virtual: '#8888cc',
cluster: '#ff8800',
fibre: '#00e5ff',
},
edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a',
@@ -300,6 +312,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' },
@@ -328,6 +342,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00cc33',
virtual: '#004400',
cluster: '#33ff66',
fibre: '#00ffcc',
},
edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100',
@@ -361,6 +376,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' },
@@ -389,6 +406,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff',
virtual: '#8b949e',
cluster: '#ff6e00',
fibre: '#22d3ee',
},
edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22',
+128 -78
View File
@@ -4,87 +4,133 @@ from mcp.types import Tool, TextContent
from .backend_client import backend
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
# create_node and update_node both expose these so the MCP is symmetric with what the
# backend already validates and stores. _dispatch forwards args verbatim, so any field
# advertised here is accepted by the backend.
_NODE_FIELDS = {
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"mac": {"type": "string", "description": "MAC address."},
"os": {"type": "string", "description": "Operating system / distribution."},
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]},
"check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."},
"check_target": {"type": "string", "description": "Target host/URL used by the status check."},
"services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."},
"notes": {"type": "string", "description": "Free-text notes / documentation for the node."},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
"container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."},
"custom_icon": {"type": "string", "description": "Override icon name for the node."},
"cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."},
"cpu_model": {"type": "string", "description": "CPU model name."},
"ram_gb": {"type": "number", "description": "RAM in gigabytes."},
"disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."},
"show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."},
"properties": {
"type": "array",
"description": "Arbitrary key/value metadata shown on the node.",
"items": {
"type": "object",
"required": ["name", "value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
},
},
},
}
def _build_tools() -> list[Tool]:
create_node_props = {
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"}
update_node_props = {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
return [
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object",
"required": ["type", "label"],
"properties": create_node_props,
}),
Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object",
"required": ["id"],
"properties": update_node_props,
}),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
"label": {"type": "string"},
},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
"type": "object",
"properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
},
}),
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
"label": {"type": "string"},
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
TOOLS = _build_tools()
def register_tools(server: Server):
@server.list_tools()
async def list_tools():
return [
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object",
"required": ["type", "label"],
"properties": {
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
},
}),
Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string"},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
},
}),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
"label": {"type": "string"},
},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
"type": "object",
"properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
},
}),
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
"label": {"type": "string"},
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
return TOOLS
@server.call_tool()
async def call_tool(name: str, arguments: dict):
@@ -94,7 +140,11 @@ def register_tools(server: Server):
def _slim_canvas(raw: dict) -> dict:
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
NODE_KEEP = {
"id", "type", "label", "ip", "hostname", "mac", "os", "status", "services",
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
"disk_gb", "parentId",
}
EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict:
+83 -1
View File
@@ -1,6 +1,6 @@
import pytest
from unittest.mock import AsyncMock, patch
from app.tools import _dispatch
from app.tools import TOOLS, _dispatch
@pytest.fixture
@@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend):
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
@pytest.mark.anyio
async def test_create_node_full_properties(mock_backend):
args = {
"type": "proxmox",
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"services": [{"name": "ssh", "port": 22}],
"cpu_count": 16,
"cpu_model": "Ryzen 9 5950X",
"ram_gb": 64,
"disk_gb": 2000,
"show_hardware": True,
"properties": [{"name": "rack", "value": "A1"}],
}
await _dispatch("create_node", dict(args))
# All extra fields forwarded to the backend unchanged.
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
@pytest.mark.anyio
async def test_update_node_properties(mock_backend):
await _dispatch("update_node", {
"id": "42",
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
@pytest.mark.anyio
async def test_delete_node(mock_backend):
await _dispatch("delete_node", {"id": "42"})
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
assert "viewport" not in result
@pytest.mark.anyio
async def test_get_canvas_keeps_documentation_fields(mock_backend):
mock_backend.get = AsyncMock(return_value={
"nodes": [
{
"id": "n1",
"type": "proxmox",
"position": {"x": 0, "y": 0},
"data": {
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"cpu_count": 16,
"ram_gb": 64,
"properties": [{"name": "rack", "value": "A1"}],
},
}
],
"edges": [],
})
result = await _dispatch("get_canvas", {})
node = result["nodes"][0]
assert node["os"] == "Proxmox VE 8"
assert node["notes"] == "Main hypervisor"
assert node["cpu_count"] == 16
assert node["ram_gb"] == 64
assert node["properties"] == [{"name": "rack", "value": "A1"}]
def _tool_schema(name: str) -> dict:
tool = next(t for t in TOOLS if t.name == name)
return tool.inputSchema["properties"]
def test_create_node_schema_exposes_full_node_fields():
props = _tool_schema("create_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"create_node schema missing {field}"
# type stays an enum of the canonical node types
assert "enum" in props["type"]
def test_update_node_schema_exposes_full_node_fields():
props = _tool_schema("update_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"update_node schema missing {field}"
assert "id" in props
@pytest.mark.anyio
async def test_list_nodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])