Compare commits

..

13 Commits

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

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

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

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

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

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

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

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

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

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

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

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

ha-relevant: yes
2026-05-31 01:54:24 +02:00
Rémy 4ba04660c8 Merge pull request #175 from Pouzor/fix/zeroconf-cve-bump
fix(deps): bump zeroconf to 0.149.7 for CVE fixes
2026-05-31 01:33:12 +02:00
Pouzor 4b06ce6ef6 fix(deps): bump zeroconf 0.131.0 -> 0.149.7 for CVE fixes
Resolves CVE-2026-47180/47183/47184 flagged by Security workflow
pip-audit on main. Scanner mDNS API (AsyncServiceBrowser, async_request)
unchanged; scan tests pass.

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

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

Backport of homelable-hacs PR #23.

ha-relevant: yes
2026-05-29 17:56:51 +02:00
34 changed files with 703 additions and 107 deletions
+1 -1
View File
@@ -1 +1 @@
2.2.0 2.3.0
+35 -2
View File
@@ -20,6 +20,34 @@ from app.services.zigbee_service import build_zigbee_properties
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"} _ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
"""Build a NodeProperty list carrying a device MAC address.
Shape matches the frontend ``NodeProperty`` type
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
showing it on the canvas card from the right panel. Returns an empty list
when no MAC is known.
"""
if not mac:
return []
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
def merge_mac_property(
props: list[dict[str, Any]] | None, mac: str | None
) -> list[dict[str, Any]]:
"""Append a MAC NodeProperty to ``props`` unless one is already present.
Preserves any user-supplied properties (and an existing MAC row's
visibility) untouched. Used on approve so the scanned MAC is not lost.
"""
out = [dict(p) for p in (props or [])]
if not mac or any(p.get("key") == "MAC" for p in out):
return out
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
return out
class BulkActionRequest(BaseModel): class BulkActionRequest(BaseModel):
device_ids: list[str] device_ids: list[str]
@@ -134,13 +162,14 @@ async def bulk_approve_devices(
label=device.hostname or device.friendly_name or device.ip or "device", label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type, type=node_type,
ip=device.ip, ip=device.ip,
mac=device.mac,
hostname=device.hostname, hostname=device.hostname,
status="online" if is_zigbee else "unknown", status="online" if is_zigbee else "unknown",
services=device.services or [], services=device.services or [],
ieee_address=device.ieee_address, ieee_address=device.ieee_address,
properties=build_zigbee_properties( properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi device.ieee_address, device.vendor, device.model, device.lqi
) if is_zigbee else [], ) if is_zigbee else build_mac_property(device.mac),
# Default to ping so the status checker actually polls the new node. # Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check). # Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_zigbee else ("ping" if device.ip else None), check_method="none" if is_zigbee else ("ping" if device.ip else None),
@@ -234,17 +263,21 @@ async def approve_device(
raise HTTPException(status_code=409, detail="Device already processed") raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved" device.status = "approved"
_is_zigbee = node_data.type in _ZIGBEE_TYPES _is_zigbee = node_data.type in _ZIGBEE_TYPES
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
node = Node( node = Node(
label=node_data.label, label=node_data.label,
type=node_data.type, type=node_data.type,
ip=node_data.ip, ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname, hostname=node_data.hostname,
status="online" if _is_zigbee else node_data.status, status="online" if _is_zigbee else node_data.status,
services=node_data.services or [], services=node_data.services or [],
ieee_address=device.ieee_address, ieee_address=device.ieee_address,
properties=build_zigbee_properties( properties=build_zigbee_properties(
device.ieee_address, device.vendor, device.model, device.lqi device.ieee_address, device.vendor, device.model, device.lqi
) if _is_zigbee else (node_data.properties or []), ) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target, check_target=None if _is_zigbee else node_data.check_target,
) )
+7 -1
View File
@@ -223,7 +223,13 @@ async def _persist_pending_import(
pending.vendor = n.get("vendor") or pending.vendor pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None: if n.get("lqi") is not None:
pending.lqi = n.get("lqi") pending.lqi = n.get("lqi")
if pending.status == "hidden": if pending.status == "approved":
# The device was approved earlier but its canvas Node no longer
# exists (no Node matched the IEEE above) — it was deleted. Revive
# the row to "pending" so it reappears in the Pending list on
# re-import instead of being silently swallowed. (Issue #167)
pending.status = "pending"
elif pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields. # Re-imported a hidden device → leave it hidden, just refresh fields.
pass pass
pending_updated += 1 pending_updated += 1
+1 -1
View File
@@ -15,7 +15,7 @@ pyyaml==6.0.2
types-PyYAML==6.0.12.20240917 types-PyYAML==6.0.12.20240917
websockets==13.1 websockets==13.1
httpx==0.27.2 httpx==0.27.2
zeroconf==0.131.0 zeroconf==0.149.7
aiomqtt==2.3.0 aiomqtt==2.3.0
# Dev # Dev
+138
View File
@@ -698,6 +698,144 @@ async def test_bulk_approve_zigbee_populates_properties(
assert node.check_method == "none" assert node.check_method == "none"
# --- MAC address propagation on approve (issue #168) ---
def test_build_mac_property_returns_hidden_row():
from app.api.routes.scan import build_mac_property
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
def test_build_mac_property_empty_when_no_mac():
from app.api.routes.scan import build_mac_property
assert build_mac_property(None) == []
assert build_mac_property("") == []
def test_merge_mac_property_appends_when_absent():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
# Existing prop preserved untouched.
assert existing[0] in merged
def test_merge_mac_property_idempotent_and_preserves_visibility():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
# No duplicate MAC row; user's visible=True choice kept.
macs = [p for p in merged if p["key"] == "MAC"]
assert len(macs) == 1
assert macs[0]["visible"] is True
def test_merge_mac_property_noop_without_mac():
from app.api.routes.scan import merge_mac_property
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
assert merge_mac_property(existing, None) == existing
@pytest.mark.asyncio
async def test_approve_device_copies_mac_to_node_and_properties(
client: AsyncClient, headers, pending_device, db_session
):
"""Approving a scanned device must carry its MAC onto the node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
# Payload intentionally omits mac — it must come from the pending device.
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
assert node.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
@pytest.mark.asyncio
async def test_approve_device_does_not_duplicate_mac_property(
client: AsyncClient, headers, pending_device, db_session
):
"""If the approve payload already carries a MAC prop, don't add a second one."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
res = await client.post(
f"/api/v1/scan/pending/{pending_device.id}/approve",
json={
"label": "My Server",
"type": "server",
"ip": "192.168.1.100",
"status": "unknown",
"services": [],
"properties": [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
],
},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert len(mac_props) == 1
# User's visibility choice is preserved.
assert mac_props[0]["visible"] is True
@pytest.mark.asyncio
async def test_bulk_approve_copies_mac_to_node_and_properties(
client: AsyncClient, headers, db_session
):
"""Bulk approve must also propagate the scanned MAC to node + properties."""
from sqlalchemy import select
from app.db.models import Node as NodeModel
device = PendingDevice(
id=str(uuid.uuid4()),
ip="192.168.1.55",
mac="11:22:33:44:55:66",
hostname="host-mac",
services=[],
suggested_type="generic",
status="pending",
)
db_session.add(device)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/bulk-approve",
json={"device_ids": [device.id]},
headers=headers,
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
).scalar_one()
assert node.mac == "11:22:33:44:55:66"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session): async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
"""Approved devices with an IP must default to ping; otherwise scheduler skips them.""" """Approved devices with an IP must default to ping; otherwise scheduler skips them."""
+86
View File
@@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
assert all(p["visible"] is False for p in refreshed.properties) assert all(p["visible"] is False for p in refreshed.properties)
@pytest.mark.asyncio
async def test_persist_pending_import_revives_orphaned_approved_device(
db_session,
) -> None:
"""Regression for #167: approve → delete node → re-import must re-list device.
When a device was approved (PendingDevice.status="approved") and its canvas
Node was later deleted, the orphaned "approved" row must be reset to
"pending" on re-import so it shows up in the Pending list again — instead of
being silently swallowed (re-import reports "found" but Pending stays empty).
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
# Simulate prior approve: a PendingDevice marked approved, but NO matching
# Node exists (the user deleted the canvas node afterwards).
orphan = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
hostname="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
model="CC2530",
vendor="TI",
lqi=220,
status="approved",
discovery_source="zigbee",
)
db_session.add(orphan)
await db_session.commit()
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
# No new row created for 0xR1 — the existing one was updated/revived.
revived = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert revived.status == "pending"
# End device 0xE1 is brand new → created as pending; router was updated.
assert result.pending_created == 1
assert result.pending_updated == 1
# It is now visible to the Pending list (status filter == "pending").
listed = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.status == "pending")
)
).scalars().all()
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
@pytest.mark.asyncio
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
db_session,
) -> None:
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import PendingDevice
hidden = PendingDevice(
ieee_address="0xR1",
friendly_name="router_1",
suggested_type="zigbee_router",
device_subtype="Router",
status="hidden",
discovery_source="zigbee",
)
db_session.add(hidden)
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
still_hidden = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None: async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
"""If user has already made props visible, re-import must not flip them back.""" """If user has already made props visible, re-import must not flip them back."""
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "2.2.0", "version": "2.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "2.2.0", "version": "2.3.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.2.0", "@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4", "@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "2.2.0", "version": "2.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
vi.mock('@/utils/handleUtils', () => ({ vi.mock('@/utils/handleUtils', () => ({
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`, bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: () => [50], bottomHandlePositions: (count: number) => {
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
},
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1, clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
})) }))
@@ -171,6 +174,29 @@ describe('BaseNode — properties rendering', () => {
}) })
}) })
describe('BaseNode — port numbers (issue #20)', () => {
it('renders a number above each bottom handle when show_port_numbers is on', () => {
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.getByText('3')).toBeDefined()
expect(screen.getByText('4')).toBeDefined()
})
it('does not render port numbers when show_port_numbers is off', () => {
renderBaseNode({ bottom_handles: 4 })
expect(screen.queryByText('1')).toBeNull()
expect(screen.queryByText('4')).toBeNull()
})
it('numbers match the handle count', () => {
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getByText('2')).toBeDefined()
expect(screen.queryByText('3')).toBeNull()
})
})
describe('BaseNode — services visibility toggle', () => { describe('BaseNode — services visibility toggle', () => {
it('does not render service toggle button on the node', () => { it('does not render service toggle button on the node', () => {
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] }) renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
@@ -0,0 +1,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, vlan: HomelableEdge,
virtual: HomelableEdge, virtual: HomelableEdge,
cluster: HomelableEdge, cluster: HomelableEdge,
fibre: HomelableEdge,
} }
@@ -323,6 +323,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
vlan: { strokeWidth: 2.5 }, vlan: { strokeWidth: 2.5 },
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' }, virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
} }
const customColor = data?.custom_color as string | undefined 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` const targetId = `${sourceId}-t`
return ( return (
<span key={sourceId}> <span key={sourceId}>
{data.show_port_numbers && (
<span
className="absolute font-mono leading-none pointer-events-none select-none"
style={{
left: `${leftPct}%`,
bottom: 3,
transform: 'translateX(-50%)',
fontSize: 7,
color: theme.colors.nodeSubtextColor,
}}
>
{idx + 1}
</span>
)}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
'generic', 'generic',
] ]
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
const NODE_ICONS: Record<string, LucideIcon> = { const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
@@ -513,6 +513,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
<span>{MIN_BOTTOM_HANDLES}</span> <span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span> <span>{MAX_BOTTOM_HANDLES}</span>
</div> </div>
<div className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.show_port_numbers}
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
tabIndex={0}
aria-label="Toggle port numbers"
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
/>
</button>
</div>
</div> </div>
)} )}
@@ -10,6 +10,7 @@ import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal' import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types' import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties' import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildMacProperty } from '@/utils/macProperty'
interface PendingDevicesModalProps { interface PendingDevicesModalProps {
open: boolean open: boolean
@@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const fallbackLabel = deviceLabel(device) const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType const type = (device.suggested_type ?? 'generic') as NodeType
const zigbee = isZigbeeType(type) const zigbee = isZigbeeType(type)
const properties = zigbee ? buildZigbeeProperties(device) : [] const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
const nodeData = { const nodeData = {
label: fallbackLabel, label: fallbackLabel,
type, type,
ip: device.ip ?? undefined, ip: device.ip ?? undefined,
mac: device.mac ?? undefined,
hostname: device.hostname ?? undefined, hostname: device.hostname ?? undefined,
status: zigbee ? 'online' : 'unknown', status: zigbee ? 'online' : 'unknown',
services: (device.services ?? []) as ServiceInfo[], services: (device.services ?? []) as ServiceInfo[],
@@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
label: deviceLabel(d), label: deviceLabel(d),
type, type,
ip: d.ip ?? undefined, ip: d.ip ?? undefined,
mac: d.mac ?? undefined,
hostname: d.hostname ?? undefined, hostname: d.hostname ?? undefined,
status: zigbee ? ('online' as const) : ('unknown' as const), status: zigbee ? ('online' as const) : ('unknown' as const),
services: (d.services ?? []) as ServiceInfo[], services: (d.services ?? []) as ServiceInfo[],
properties: zigbee ? buildZigbeeProperties(d) : [], properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
}, },
}) })
}) })
@@ -59,6 +59,13 @@ describe('EdgeModal', () => {
expect(onSubmit.mock.calls[0][0].label).toBeUndefined() expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
}) })
it('round-trips the fibre type through submit (issue #21)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'fibre' }} />)
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].type).toBe('fibre')
})
// ── VLAN ID field ───────────────────────────────────────────────────────── // ── VLAN ID field ─────────────────────────────────────────────────────────
it('does not show VLAN ID field for ethernet type', () => { it('does not show VLAN ID field for ethernet type', () => {
@@ -416,20 +416,30 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
}) })
it('supports the full 1..48 range', () => { it('supports the full 1..64 range (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE }) const { onSubmit } = renderModal({ initial: BASE })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1') expect(slider.min).toBe('1')
expect(slider.max).toBe('48') expect(slider.max).toBe('64')
fireEvent.change(slider, { target: { value: '48' } }) fireEvent.change(slider, { target: { value: '52' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' })) fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48) expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
}) })
it('clamps pre-filled out-of-range values into [1,48]', () => { it('clamps pre-filled out-of-range values into [1,64]', () => {
renderModal({ initial: { ...BASE, bottom_handles: 9999 } }) renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('48') expect(slider.value).toBe('64')
})
it('toggles show_port_numbers and submits it (issue #20)', () => {
const { onSubmit } = renderModal({ initial: BASE })
const toggle = screen.getByLabelText('Toggle port numbers')
expect(toggle.getAttribute('aria-checked')).toBe('false')
fireEvent.click(toggle)
expect(toggle.getAttribute('aria-checked')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).show_port_numbers).toBe(true)
}) })
// ── Zigbee nodes ────────────────────────────────────────────────────── // ── Zigbee nodes ──────────────────────────────────────────────────────
@@ -13,6 +13,7 @@ const mockApprove = vi.fn()
const mockHide = vi.fn() const mockHide = vi.fn()
const mockPending = vi.fn() const mockPending = vi.fn()
const mockHidden = vi.fn() const mockHidden = vi.fn()
const mockAddNode = vi.fn()
vi.mock('@/api/client', () => ({ vi.mock('@/api/client', () => ({
scanApi: { scanApi: {
@@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
addNode: vi.fn(), addNode: mockAddNode,
scanEventTs: 0, scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>) } as unknown as ReturnType<typeof useCanvasStore>)
// setState is used by injectAutoEdges // setState is used by injectAutoEdges
@@ -174,6 +175,34 @@ describe('PendingDevicesModal', () => {
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'])) await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
}) })
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
await waitFor(() => expect(mockAddNode).toHaveBeenCalledTimes(2))
// dev-a is an IP device with a MAC → node carries mac + a MAC property row.
const ipNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n1')
expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01')
expect(ipNode.data.properties).toContainEqual({
key: 'MAC',
value: 'aa:bb:cc:dd:ee:01',
icon: null,
visible: false,
})
// dev-b is zigbee with no MAC → no MAC property row.
const zbNode = mockAddNode.mock.calls
.map((c) => c[0])
.find((n) => n.id === 'n2')
expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false)
})
it('bulk hide calls API with selected ids', async () => { it('bulk hide calls API with selected ids', async () => {
render(<PendingDevicesModal {...baseProps} />) render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
+1 -1
View File
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
describe('EDGE_TYPE_LABELS', () => { describe('EDGE_TYPE_LABELS', () => {
it('has an entry for every edge type', () => { it('has an entry for every edge type', () => {
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
expectedTypes.forEach((t) => { expectedTypes.forEach((t) => {
expect(EDGE_TYPE_LABELS).toHaveProperty(t) expect(EDGE_TYPE_LABELS).toHaveProperty(t)
}) })
+5 -2
View File
@@ -37,7 +37,7 @@ export type TextPosition =
| 'bottom-center' | 'bottom-center'
| 'bottom-right' | 'bottom-right'
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre'
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown' export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -106,8 +106,10 @@ export interface NodeData extends Record<string, unknown> {
*/ */
collapsed?: boolean collapsed?: boolean
custom_icon?: string custom_icon?: string
/** Number of bottom connection points, 1..48. Default 1 (centered). */ /** Number of bottom connection points, 1..64. Default 1 (centered). */
bottom_handles?: number bottom_handles?: number
/** Show a port number (1..N) above each bottom connection point. */
show_port_numbers?: boolean
/** Text node content (type === 'text') */ /** Text node content (type === 'text') */
text_content?: string text_content?: string
} }
@@ -173,6 +175,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
vlan: 'VLAN', vlan: 'VLAN',
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
fibre: 'Fibre',
} }
export interface NodeTypeStyle { export interface NodeTypeStyle {
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
import { EDGE_DEFAULT_COLORS } from '../edgeColors' import { EDGE_DEFAULT_COLORS } from '../edgeColors'
import type { EdgeType } from '@/types' import type { EdgeType } from '@/types'
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
describe('EDGE_DEFAULT_COLORS', () => { describe('EDGE_DEFAULT_COLORS', () => {
it('has an entry for every EdgeType', () => { it('has an entry for every EdgeType', () => {
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
it('cluster default is proxmox orange', () => { it('cluster default is proxmox orange', () => {
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00') expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
}) })
it('fibre default is bright cyan', () => {
expect(EDGE_DEFAULT_COLORS.fibre).toBe('#22d3ee')
})
}) })
@@ -102,6 +102,15 @@ describe('exportCanvasToYaml', () => {
expect(entryA).not.toHaveProperty('clusterR') expect(entryA).not.toHaveProperty('clusterR')
}) })
it('serializes a fibre edge with linkType "fibre" (issue #21)', () => {
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
const edge = makeEdge('e1', 'sw', 's1', { type: 'fibre', label: 'sfp0' })
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
const entryA = result.find((e) => e.label === 'Switch')!
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'fibre', linkLabel: 'sfp0' }])
})
it('serializes multiple outgoing edges as links array', () => { it('serializes multiple outgoing edges as links array', () => {
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw') const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1') const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES) expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
}) })
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
expect(MAX_BOTTOM_HANDLES).toBeGreaterThanOrEqual(52)
})
it('clamps above MAX to MAX', () => { it('clamps above MAX to MAX', () => {
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES) expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES) expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
}) })
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(1)).toBe(1) expect(clampBottomHandles(1)).toBe(1)
expect(clampBottomHandles(24)).toBe(24) expect(clampBottomHandles(24)).toBe(24)
expect(clampBottomHandles(48)).toBe(48) expect(clampBottomHandles(48)).toBe(48)
expect(clampBottomHandles(52)).toBe(52)
expect(clampBottomHandles(64)).toBe(64)
}) })
}) })
@@ -89,6 +89,22 @@ describe('parseYamlToCanvas', () => {
expect(edges[0].targetHandle).toBe('top-t') expect(edges[0].targetHandle).toBe('top-t')
}) })
it('imports a fibre link type onto the edge (issue #21)', () => {
const yaml = `
- nodeType: switch
label: "SW"
links:
- label: "SRV"
linkType: fibre
- nodeType: server
label: "SRV"
`
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(edges).toHaveLength(1)
expect(edges[0].type).toBe('fibre')
expect(edges[0].data?.type).toBe('fibre')
})
it('cluster edges have cluster-right→cluster-left handles', () => { it('cluster edges have cluster-right→cluster-left handles', () => {
const yaml = ` const yaml = `
- nodeType: proxmox - nodeType: proxmox
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest'
import { buildMacProperty } from '../macProperty'
describe('buildMacProperty', () => {
it('returns a hidden MAC property row for a MAC', () => {
expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([
{ key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false },
])
})
it('returns an empty array when MAC is null/undefined/empty', () => {
expect(buildMacProperty(null)).toEqual([])
expect(buildMacProperty(undefined)).toEqual([])
expect(buildMacProperty('')).toEqual([])
})
})
+1 -1
View File
@@ -6,7 +6,7 @@ const NODE_TYPES: NodeType[] = [
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect', 'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
] ]
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster'] const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown'] const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
describe('THEME_ORDER', () => { describe('THEME_ORDER', () => {
+2
View File
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
width?: number | null width?: number | null
height?: number | null height?: number | null
bottom_handles?: number bottom_handles?: number
show_port_numbers?: boolean
} }
export interface ApiEdge { export interface ApiEdge {
@@ -114,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
width: n.measured?.width ?? n.width ?? null, width: n.measured?.width ?? n.width ?? null,
height: n.measured?.height ?? n.height ?? null, height: n.measured?.height ?? n.height ?? null,
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1), bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
show_port_numbers: n.data.show_port_numbers ?? false,
pos_x: n.position.x, pos_x: n.position.x,
pos_y: n.position.y, pos_y: n.position.y,
} }
+1
View File
@@ -7,4 +7,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
} }
+3 -3
View File
@@ -2,14 +2,14 @@
* Bottom handle configuration for multi-handle nodes. * Bottom handle configuration for multi-handle nodes.
* *
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible) * Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
* index 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: * Invisible target handles follow the same pattern with a '-t' suffix:
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t' * 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
*/ */
export const MIN_BOTTOM_HANDLES = 1 export const MIN_BOTTOM_HANDLES = 1
export const MAX_BOTTOM_HANDLES = 48 export const MAX_BOTTOM_HANDLES = 64
/** Returns the source handle ID at a given slot index. */ /** Returns the source handle ID at a given slot index. */
export function bottomHandleId(idx: number): string { export function bottomHandleId(idx: number): string {
+9
View File
@@ -0,0 +1,9 @@
import type { NodeProperty } from '@/types'
/** Build the MAC address property row shown in the right panel.
* Hidden by default — the user opts in to showing it on the canvas card.
* Matches backend `build_mac_property`. Returns an empty array when no MAC. */
export function buildMacProperty(mac?: string | null): NodeProperty[] {
if (!mac) return []
return [{ key: 'MAC', value: mac, icon: null, visible: false }]
}
+6
View File
@@ -86,6 +86,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
@@ -149,6 +150,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#22d3ee', vlan: '#22d3ee',
virtual: '#6b7280', virtual: '#6b7280',
cluster: '#fb923c', cluster: '#fb923c',
fibre: '#06b6d4',
}, },
edgeSelectedColor: '#22d3ee', edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111', edgeLabelBackground:'#111111',
@@ -212,6 +214,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#0284c7', vlan: '#0284c7',
virtual: '#9ca3af', virtual: '#9ca3af',
cluster: '#ea580c', cluster: '#ea580c',
fibre: '#0891b2',
}, },
edgeSelectedColor: '#0284c7', edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff', edgeLabelBackground:'#ffffff',
@@ -275,6 +278,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00ffff', vlan: '#00ffff',
virtual: '#8888cc', virtual: '#8888cc',
cluster: '#ff8800', cluster: '#ff8800',
fibre: '#00e5ff',
}, },
edgeSelectedColor: '#00ffff', edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a', edgeLabelBackground:'#0a0a1a',
@@ -338,6 +342,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00cc33', vlan: '#00cc33',
virtual: '#004400', virtual: '#004400',
cluster: '#33ff66', cluster: '#33ff66',
fibre: '#00ffcc',
}, },
edgeSelectedColor: '#00ff41', edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100', edgeLabelBackground:'#001100',
@@ -401,6 +406,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
vlan: '#00d4ff', vlan: '#00d4ff',
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
+128 -78
View File
@@ -4,87 +4,133 @@ from mcp.types import Tool, TextContent
from .backend_client import backend from .backend_client import backend
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
# create_node and update_node both expose these so the MCP is symmetric with what the
# backend already validates and stores. _dispatch forwards args verbatim, so any field
# advertised here is accepted by the backend.
_NODE_FIELDS = {
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"mac": {"type": "string", "description": "MAC address."},
"os": {"type": "string", "description": "Operating system / distribution."},
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]},
"check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."},
"check_target": {"type": "string", "description": "Target host/URL used by the status check."},
"services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."},
"notes": {"type": "string", "description": "Free-text notes / documentation for the node."},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
"container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."},
"custom_icon": {"type": "string", "description": "Override icon name for the node."},
"cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."},
"cpu_model": {"type": "string", "description": "CPU model name."},
"ram_gb": {"type": "number", "description": "RAM in gigabytes."},
"disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."},
"show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."},
"properties": {
"type": "array",
"description": "Arbitrary key/value metadata shown on the node.",
"items": {
"type": "object",
"required": ["name", "value"],
"properties": {
"name": {"type": "string"},
"value": {"type": "string"},
},
},
},
}
def _build_tools() -> list[Tool]:
create_node_props = {
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"}
update_node_props = {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES},
**_NODE_FIELDS,
}
return [
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object",
"required": ["type", "label"],
"properties": create_node_props,
}),
Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object",
"required": ["id"],
"properties": update_node_props,
}),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
"label": {"type": "string"},
},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
"type": "object",
"properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
},
}),
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
"label": {"type": "string"},
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
TOOLS = _build_tools()
def register_tools(server: Server): def register_tools(server: Server):
@server.list_tools() @server.list_tools()
async def list_tools(): async def list_tools():
return [ return TOOLS
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
"type": "object",
"required": ["type", "label"],
"properties": {
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
},
}),
Tool(name="update_node", description="Update an existing node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"label": {"type": "string"},
"ip": {"type": "string"},
"hostname": {"type": "string"},
"status": {"type": "string"},
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
},
}),
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
"type": "object",
"required": ["source", "target"],
"properties": {
"source": {"type": "string"},
"target": {"type": "string"},
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
"label": {"type": "string"},
},
}),
Tool(name="delete_edge", description="Delete a network link", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
"type": "object",
"properties": {
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
},
}),
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
"type": "object",
"required": ["id"],
"properties": {
"id": {"type": "string"},
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
"label": {"type": "string"},
},
}),
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
"type": "object",
"required": ["id"],
"properties": {"id": {"type": "string"}},
}),
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
"type": "object",
"properties": {},
}),
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
"type": "object",
"properties": {},
}),
]
@server.call_tool() @server.call_tool()
async def call_tool(name: str, arguments: dict): async def call_tool(name: str, arguments: dict):
@@ -94,7 +140,11 @@ def register_tools(server: Server):
def _slim_canvas(raw: dict) -> dict: def _slim_canvas(raw: dict) -> dict:
"""Strip React Flow layout/style fields — keep only semantic data for AI use.""" """Strip React Flow layout/style fields — keep only semantic data for AI use."""
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"} NODE_KEEP = {
"id", "type", "label", "ip", "hostname", "mac", "os", "status", "services",
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
"disk_gb", "parentId",
}
EDGE_KEEP = {"id", "source", "target", "type", "label"} EDGE_KEEP = {"id", "source", "target", "type", "label"}
def slim_node(n: dict) -> dict: def slim_node(n: dict) -> dict:
+83 -1
View File
@@ -1,6 +1,6 @@
import pytest import pytest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
from app.tools import _dispatch from app.tools import TOOLS, _dispatch
@pytest.fixture @pytest.fixture
@@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend):
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"}) mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
@pytest.mark.anyio
async def test_create_node_full_properties(mock_backend):
args = {
"type": "proxmox",
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"services": [{"name": "ssh", "port": 22}],
"cpu_count": 16,
"cpu_model": "Ryzen 9 5950X",
"ram_gb": 64,
"disk_gb": 2000,
"show_hardware": True,
"properties": [{"name": "rack", "value": "A1"}],
}
await _dispatch("create_node", dict(args))
# All extra fields forwarded to the backend unchanged.
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
@pytest.mark.anyio
async def test_update_node_properties(mock_backend):
await _dispatch("update_node", {
"id": "42",
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {
"os": "Debian 12",
"properties": [{"name": "role", "value": "db"}],
})
@pytest.mark.anyio @pytest.mark.anyio
async def test_delete_node(mock_backend): async def test_delete_node(mock_backend):
await _dispatch("delete_node", {"id": "42"}) await _dispatch("delete_node", {"id": "42"})
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
assert "viewport" not in result assert "viewport" not in result
@pytest.mark.anyio
async def test_get_canvas_keeps_documentation_fields(mock_backend):
mock_backend.get = AsyncMock(return_value={
"nodes": [
{
"id": "n1",
"type": "proxmox",
"position": {"x": 0, "y": 0},
"data": {
"label": "pve1",
"os": "Proxmox VE 8",
"notes": "Main hypervisor",
"cpu_count": 16,
"ram_gb": 64,
"properties": [{"name": "rack", "value": "A1"}],
},
}
],
"edges": [],
})
result = await _dispatch("get_canvas", {})
node = result["nodes"][0]
assert node["os"] == "Proxmox VE 8"
assert node["notes"] == "Main hypervisor"
assert node["cpu_count"] == 16
assert node["ram_gb"] == 64
assert node["properties"] == [{"name": "rack", "value": "A1"}]
def _tool_schema(name: str) -> dict:
tool = next(t for t in TOOLS if t.name == name)
return tool.inputSchema["properties"]
def test_create_node_schema_exposes_full_node_fields():
props = _tool_schema("create_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"create_node schema missing {field}"
# type stays an enum of the canonical node types
assert "enum" in props["type"]
def test_update_node_schema_exposes_full_node_fields():
props = _tool_schema("update_node")
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
assert field in props, f"update_node schema missing {field}"
assert "id" in props
@pytest.mark.anyio @pytest.mark.anyio
async def test_list_nodes(mock_backend): async def test_list_nodes(mock_backend):
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}]) mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])