Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b3cc1eba0 | |||
| 3da5517996 | |||
| cb26da3de5 | |||
| 23a0a47a7f | |||
| aac6c09a04 | |||
| bf90d6312b | |||
| ec15c260e1 | |||
| 47ab3a9a76 | |||
| e8bcf04b46 | |||
| 4ba04660c8 | |||
| 4b06ce6ef6 | |||
| 6761f73c17 | |||
| 1431f5b19e | |||
| b5628e18fa | |||
| dc70805673 | |||
| 05c24d622b | |||
| 541e25327b | |||
| 9823be9d78 | |||
| b71c96897a | |||
| 20e1820a4e | |||
| 517486ff79 | |||
| b5b1056ae6 | |||
| 66a9a57861 | |||
| 69aa8256f0 | |||
| 78b43a300f | |||
| 995de26591 | |||
| 525dfe5ece | |||
| d066f37e88 | |||
| 004623bae5 |
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2.1.1",
|
||||
"version": "2.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
||||
vlan: '#00d4ff',
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
|
||||
*
|
||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
|
||||
*/
|
||||
|
||||
export const MIN_BOTTOM_HANDLES = 1
|
||||
export const MAX_BOTTOM_HANDLES = 48
|
||||
export const MAX_BOTTOM_HANDLES = 64
|
||||
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
|
||||
@@ -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 }]
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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"}])
|
||||
|
||||
Reference in New Issue
Block a user