Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d527aad6 | |||
| eb7b0c6d38 | |||
| be320c2ac4 | |||
| 60ce648a96 | |||
| d5e35a885d | |||
| 3a57d809a4 | |||
| 46435605eb |
@@ -1,13 +1,14 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -16,10 +17,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
async def load_canvas(
|
||||
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> CanvasStateResponse:
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
@@ -32,18 +43,28 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
@router.post("/save")
|
||||
async def save_canvas(
|
||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
) -> dict[str, bool | str]:
|
||||
design_id = body.design_id
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
|
||||
db.add(new_design)
|
||||
await db.flush()
|
||||
design_id = new_design.id
|
||||
|
||||
incoming_node_ids = {n.id for n in body.nodes}
|
||||
incoming_edge_ids = {e.id for e in body.edges}
|
||||
|
||||
# Delete nodes removed from canvas
|
||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
||||
# Delete nodes removed from canvas (only within this design)
|
||||
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for node in existing_nodes:
|
||||
if node.id not in incoming_node_ids:
|
||||
await db.delete(node)
|
||||
|
||||
# Delete edges removed from canvas
|
||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
||||
# Delete edges removed from canvas (only within this design)
|
||||
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for edge in existing_edges:
|
||||
if edge.id not in incoming_edge_ids:
|
||||
await db.delete(edge)
|
||||
@@ -53,29 +74,33 @@ async def save_canvas(
|
||||
# Upsert nodes
|
||||
for node_data in body.nodes:
|
||||
db_node = await db.get(Node, node_data.id)
|
||||
payload = node_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_node:
|
||||
for field, value in node_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**node_data.model_dump()))
|
||||
db.add(Node(**payload))
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
db_edge = await db.get(Edge, edge_data.id)
|
||||
payload = edge_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_edge:
|
||||
for field, value in edge_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_edge, field, value)
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
db.add(Edge(**payload))
|
||||
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, 1)
|
||||
state = await db.get(CanvasState, design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
|
||||
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DesignResponse])
|
||||
async def list_designs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[DesignResponse]:
|
||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
||||
return [DesignResponse.model_validate(d) for d in designs]
|
||||
|
||||
|
||||
@router.post("", response_model=DesignResponse, status_code=201)
|
||||
async def create_design(
|
||||
body: DesignCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
# Create empty canvas state for the new design
|
||||
db.add(CanvasState(design_id=design.id))
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.put("/{design_id}", response_model=DesignResponse)
|
||||
async def update_design(
|
||||
design_id: str,
|
||||
body: DesignUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
if body.name is not None:
|
||||
design.name = body.name
|
||||
if body.icon is not None:
|
||||
design.icon = body.icon
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.delete("/{design_id}", status_code=204)
|
||||
async def delete_design(
|
||||
design_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> None:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
# Count remaining designs — prevent deleting the last one
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, edges, nodes
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for n in nodes:
|
||||
await db.delete(n)
|
||||
await db.delete(design)
|
||||
await db.commit()
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -18,6 +18,7 @@ router = APIRouter()
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def liveview_canvas(
|
||||
key: str | None = Query(default=None),
|
||||
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CanvasStateResponse:
|
||||
"""Read-only public canvas endpoint.
|
||||
@@ -30,9 +31,15 @@ async def liveview_canvas(
|
||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
return CanvasStateResponse(
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
@@ -20,34 +20,6 @@ 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]
|
||||
|
||||
@@ -146,6 +118,10 @@ async def bulk_approve_devices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design (use first design as fallback)
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
default_design_id = first_design.id if first_design else None
|
||||
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
@@ -162,17 +138,17 @@ 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 build_mac_property(device.mac),
|
||||
) if is_zigbee else [],
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||
design_id=default_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
created_nodes.append(node)
|
||||
@@ -256,6 +232,12 @@ async def approve_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design
|
||||
node_design_id = node_data.design_id
|
||||
if node_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
@@ -263,23 +245,20 @@ 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 merge_mac_property(node_data.properties, _mac),
|
||||
) if _is_zigbee else (node_data.properties or []),
|
||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if _is_zigbee else node_data.check_target,
|
||||
design_id=node_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
@@ -361,12 +340,18 @@ async def _resolve_pending_links_for_ieee(
|
||||
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
||||
await db.delete(link)
|
||||
continue
|
||||
# Use the source node's design_id for the edge
|
||||
edge_design_id = self_node.design_id if self_node else None
|
||||
if edge_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
edge_design_id = first.id if first else None
|
||||
edge = Edge(
|
||||
source=src_id,
|
||||
target=tgt_id,
|
||||
type="iot",
|
||||
source_handle="bottom",
|
||||
target_handle="top-t",
|
||||
design_id=edge_design_id,
|
||||
)
|
||||
db.add(edge)
|
||||
await db.flush()
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeCoordinatorOut,
|
||||
@@ -138,6 +138,10 @@ async def _persist_pending_import(
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
||||
All zigbee-source links are wiped and re-inserted from the new map.
|
||||
"""
|
||||
# Determine target design (use first design as fallback)
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
default_design_id = first_design.id if first_design else None
|
||||
|
||||
coordinator_out: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_existed = False
|
||||
pending_created = 0
|
||||
@@ -174,6 +178,7 @@ async def _persist_pending_import(
|
||||
ieee_address=ieee,
|
||||
services=[],
|
||||
properties=props,
|
||||
design_id=default_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
@@ -223,13 +228,7 @@ 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 == "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":
|
||||
if pending.status == "hidden":
|
||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||
pass
|
||||
pending_updated += 1
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json as _json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
@@ -168,20 +170,113 @@ async def init_db() -> None:
|
||||
except OperationalError as exc:
|
||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
await _try_migrate(
|
||||
conn,
|
||||
"CREATE TABLE IF NOT EXISTS designs ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"name VARCHAR NOT NULL,"
|
||||
"design_type VARCHAR NOT NULL DEFAULT 'network',"
|
||||
"created_at DATETIME,"
|
||||
"updated_at DATETIME"
|
||||
")",
|
||||
label="designs.table",
|
||||
)
|
||||
# Add user-chosen icon to designs (idempotent), then backfill existing rows
|
||||
# so legacy designs keep a sensible icon based on their original type.
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
|
||||
)
|
||||
# Seed default Network Topology design if designs table is empty
|
||||
_default_design_id = str(_uuid_mod.uuid4())
|
||||
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
|
||||
count_row = row.fetchone()
|
||||
count = count_row[0] if count_row else 0
|
||||
if count == 0:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
|
||||
(_default_design_id,),
|
||||
)
|
||||
else:
|
||||
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
|
||||
default = row2.fetchone()
|
||||
_default_design_id = default[0] if default else _default_design_id
|
||||
|
||||
# Add design_id to nodes
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="nodes.design_id",
|
||||
)
|
||||
# Assign existing nodes to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Add design_id to edges
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="edges.design_id",
|
||||
)
|
||||
# Assign existing edges to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
|
||||
try:
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
|
||||
cols = info.fetchall()
|
||||
has_design_id = any(c[1] == "design_id" for c in cols)
|
||||
if not has_design_id:
|
||||
logger.info("Migrating canvas_state: switching to design_id primary key")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE canvas_state_new ("
|
||||
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
|
||||
"viewport JSON,"
|
||||
"custom_style JSON,"
|
||||
"saved_at DATETIME"
|
||||
")"
|
||||
)
|
||||
# Copy existing row(s), mapping id=1 to default design_id
|
||||
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
|
||||
for old in old_rows.fetchall():
|
||||
cs_id, viewport, custom_style, saved_at = old
|
||||
target_design = _default_design_id
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(target_design, viewport, custom_style, saved_at),
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE canvas_state")
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
|
||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
for r in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
@@ -192,7 +287,6 @@ async def init_db() -> None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
import json as _json
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(_json.dumps(props), node_id),
|
||||
|
||||
@@ -16,12 +16,24 @@ def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Design(Base):
|
||||
__tablename__ = "designs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class Node(Base):
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
ip: Mapped[str | None] = mapped_column(String)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
@@ -61,6 +73,7 @@ class Edge(Base):
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||
label: Mapped[str | None] = mapped_column(String)
|
||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||
@@ -77,7 +90,7 @@ class Edge(Base):
|
||||
class CanvasState(Base):
|
||||
__tablename__ = "canvas_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
|
||||
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
@@ -51,6 +51,7 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
|
||||
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
|
||||
edges: list[EdgeSave] = []
|
||||
viewport: dict[str, Any] = {}
|
||||
custom_style: dict[str, Any] | None = None
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class CanvasStateResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DesignCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = "dashboard"
|
||||
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
|
||||
# the chosen icon now drives presentation. Defaults to a generic canvas.
|
||||
design_type: str = "network"
|
||||
|
||||
|
||||
class DesignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
class DesignResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
design_type: str
|
||||
icon: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -26,7 +26,7 @@ class EdgeBase(BaseModel):
|
||||
|
||||
|
||||
class EdgeCreate(EdgeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class EdgeUpdate(BaseModel):
|
||||
|
||||
@@ -34,7 +34,7 @@ class NodeBase(BaseModel):
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
@@ -68,6 +68,8 @@ class NodeUpdate(BaseModel):
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
ieee_address: str | None = None
|
||||
last_seen: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
|
||||
|
||||
def node_payload(**kwargs):
|
||||
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
||||
|
||||
|
||||
def edge_payload(src, tgt, **kwargs):
|
||||
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
|
||||
|
||||
|
||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/designs")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_create_design_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/designs", json={"name": "X"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ── list / create ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_empty(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/designs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_create_design_defaults(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Workshop")
|
||||
assert design["name"] == "Workshop"
|
||||
assert design["design_type"] == "network"
|
||||
assert design["icon"] == "dashboard"
|
||||
assert "id" in design and design["id"]
|
||||
|
||||
|
||||
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Net", design_type="network")
|
||||
assert design["design_type"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Power", icon="zap")
|
||||
assert design["icon"] == "zap"
|
||||
|
||||
|
||||
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="D", icon="dashboard")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["icon"] == "server"
|
||||
# Name left untouched when only icon is sent.
|
||||
assert res.json()["name"] == "D"
|
||||
|
||||
|
||||
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Old", icon="dashboard")
|
||||
res = await client.put(
|
||||
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["name"] == "New"
|
||||
assert body["icon"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Has Canvas")
|
||||
# Loading the new design returns an (empty) canvas without falling back to another design.
|
||||
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == []
|
||||
assert body["edges"] == []
|
||||
|
||||
|
||||
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
|
||||
a = await _create(client, headers, name="First")
|
||||
b = await _create(client, headers, name="Second")
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
ids = [d["id"] for d in listed]
|
||||
assert ids == [a["id"], b["id"]]
|
||||
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Old Name")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "New Name"
|
||||
|
||||
|
||||
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Only One")
|
||||
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
|
||||
await _create(client, headers, name="Keep")
|
||||
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
|
||||
keep = await _create(client, headers, name="Keep")
|
||||
victim = await _create(client, headers, name="Victim")
|
||||
|
||||
# Populate the victim design with nodes + an edge via canvas save.
|
||||
n1 = node_payload(label="A")
|
||||
n2 = node_payload(label="B")
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
|
||||
# Populate the kept design too, to prove scoping.
|
||||
k1 = node_payload(label="K")
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
# Victim gone from list.
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [keep["id"]]
|
||||
|
||||
# Kept design's node survives untouched.
|
||||
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
|
||||
assert len(kept_canvas["nodes"]) == 1
|
||||
assert kept_canvas["nodes"][0]["label"] == "K"
|
||||
@@ -698,144 +698,6 @@ 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,92 +459,6 @@ 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.3.0",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "2.3.0",
|
||||
"version": "2.2.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+101
-28
@@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
import { canvasApi, designsApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
@@ -44,6 +45,7 @@ export default function App() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
@@ -70,29 +72,75 @@ export default function App() {
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||
const handleSave = useCallback(async () => {
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
||||
try {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
if (STANDALONE) {
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
return true
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return true
|
||||
} catch {
|
||||
toast.error('Save failed')
|
||||
return false
|
||||
}
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle])
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
} catch {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
if (STANDALONE) return
|
||||
try {
|
||||
const res = await designsApi.list()
|
||||
const loadedDesigns = res.data
|
||||
setDesigns(loadedDesigns)
|
||||
const targetId = activeDesignId ?? loadedDesigns[0]?.id
|
||||
if (targetId) {
|
||||
setActiveDesign(targetId)
|
||||
await loadCanvasFromApi(targetId)
|
||||
}
|
||||
} catch {
|
||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
|
||||
|
||||
// Load canvas on auth (or immediately in standalone mode)
|
||||
useEffect(() => {
|
||||
if (STANDALONE) {
|
||||
@@ -112,28 +160,53 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
if (!isAuthenticated) return
|
||||
canvasApi.load()
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
// Build a map of container mode nodes to know if children should be nested
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
})
|
||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
|
||||
loadDesignsAndCanvas()
|
||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
||||
|
||||
// Reload canvas when active design changes (after initial load)
|
||||
const initialLoadDone = useRef(false)
|
||||
const prevDesignRef = useRef<string | null>(null)
|
||||
// Set while we programmatically revert activeDesignId after a failed save, so
|
||||
// the re-entrant effect run skips save/load and just re-syncs the refs.
|
||||
const revertingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (revertingRef.current) {
|
||||
revertingRef.current = false
|
||||
prevDesignRef.current = activeDesignId
|
||||
return
|
||||
}
|
||||
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
||||
const oldId = prevDesignRef.current
|
||||
// If the previous design was deleted (no longer in the list), don't try to
|
||||
// save into it — just load the newly-selected design.
|
||||
const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false
|
||||
if (oldId && oldId !== activeDesignId && oldStillExists) {
|
||||
// Save current (old) canvas data under the old design ID before switching.
|
||||
// We call handleSave directly (not via ref) so it runs in this effect's
|
||||
// closure where activeDesignId is already the NEW value — the override
|
||||
// ensures data is stored under the correct design_id.
|
||||
const targetId = activeDesignId
|
||||
handleSave(oldId).then((ok) => {
|
||||
if (ok) {
|
||||
loadCanvasFromApi(targetId)
|
||||
} else {
|
||||
// Save failed: don't load the new design — that would overwrite the
|
||||
// unsaved in-memory canvas. Revert the selection back to the old
|
||||
// design so the UI matches the data still on screen.
|
||||
toast.error('Switch cancelled — unsaved changes kept')
|
||||
revertingRef.current = true
|
||||
setActiveDesign(oldId)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadCanvasFromApi(activeDesignId)
|
||||
}
|
||||
}
|
||||
if (activeDesignId) {
|
||||
prevDesignRef.current = activeDesignId
|
||||
initialLoadDone.current = true
|
||||
}
|
||||
}, [activeDesignId])
|
||||
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('api/client', () => {
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas')
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||
})
|
||||
|
||||
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
||||
|
||||
@@ -28,12 +28,16 @@ export const authApi = {
|
||||
}
|
||||
|
||||
export const canvasApi = {
|
||||
load: () => api.get('/canvas'),
|
||||
load: (design_id?: string) => {
|
||||
const params = design_id ? { design_id } : {}
|
||||
return api.get('/canvas', { params })
|
||||
},
|
||||
save: (payload: {
|
||||
nodes: object[]
|
||||
edges: object[]
|
||||
viewport: object
|
||||
custom_style?: object | null
|
||||
design_id?: string | null
|
||||
}) => api.post('/canvas/save', payload),
|
||||
}
|
||||
|
||||
@@ -89,6 +93,15 @@ export const settingsApi = {
|
||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||
}
|
||||
|
||||
export const designsApi = {
|
||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||
api.post<import('@/types').Design>('/designs', data),
|
||||
update: (id: string, data: { name?: string; icon?: string }) =>
|
||||
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||
}
|
||||
|
||||
export const zigbeeApi = {
|
||||
testConnection: (data: {
|
||||
mqtt_host: string
|
||||
|
||||
@@ -8,4 +8,5 @@ export const edgeTypes = {
|
||||
virtual: HomelableEdge,
|
||||
cluster: HomelableEdge,
|
||||
fibre: HomelableEdge,
|
||||
electrical: HomelableEdge,
|
||||
}
|
||||
|
||||
@@ -324,6 +324,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
||||
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
||||
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
|
||||
electrical: { stroke: edgeColors.electrical, strokeWidth: 2 },
|
||||
}
|
||||
|
||||
const customColor = data?.custom_color as string | undefined
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -32,3 +33,19 @@ export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||
|
||||
// Electrical node types
|
||||
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
||||
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||
export const BatteryNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||
export const GeneratorNode = (props: N) => <BaseNode {...props} icon={Fuel} />
|
||||
export const SolarPanelNode = (props: N) => <BaseNode {...props} icon={Sun} />
|
||||
export const InverterNode = (props: N) => <BaseNode {...props} icon={Repeat2} />
|
||||
export const CircuitBreakerNode = (props: N) => <BaseNode {...props} icon={Split} />
|
||||
export const ContactorNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
|
||||
export const ElectricalSwitchNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
|
||||
export const SocketNode = (props: N) => <BaseNode {...props} icon={Cable} />
|
||||
export const LightNode = (props: N) => <BaseNode {...props} icon={Lightbulb} />
|
||||
export const MeterNode = (props: N) => <BaseNode {...props} icon={Gauge} />
|
||||
export const TransformerNode = (props: N) => <BaseNode {...props} icon={Combine} />
|
||||
export const LoadNode = (props: N) => <BaseNode {...props} icon={Zap} />
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
|
||||
import {
|
||||
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
|
||||
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
||||
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
||||
LightNode, MeterNode, TransformerNode, LoadNode,
|
||||
} from './index'
|
||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||
import { GroupRectNode } from './GroupRectNode'
|
||||
import { GroupNode } from './GroupNode'
|
||||
@@ -31,4 +39,18 @@ export const nodeTypes = {
|
||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||
zigbee_router: ZigbeeRouterNode,
|
||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||
grid: GridNode,
|
||||
ups: UpsNode,
|
||||
battery: BatteryNode,
|
||||
generator: GeneratorNode,
|
||||
solar_panel: SolarPanelNode,
|
||||
inverter: InverterNode,
|
||||
circuit_breaker: CircuitBreakerNode,
|
||||
contactor: ContactorNode,
|
||||
electrical_switch: ElectricalSwitchNode,
|
||||
socket: SocketNode,
|
||||
light: LightNode,
|
||||
meter: MeterNode,
|
||||
transformer: TransformerNode,
|
||||
load: LoadNode,
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
'generic',
|
||||
]
|
||||
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
|
||||
|
||||
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
export interface DesignFormData {
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface DesignModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: DesignFormData) => void
|
||||
initial?: DesignFormData
|
||||
title?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
onSubmit({ name: trimmed, icon })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="design-name">Name</Label>
|
||||
<Input
|
||||
id="design-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder="e.g. Home Network, Rack Power"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Icon</Label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{DESIGN_ICONS.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
const selected = entry.key === icon
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-label={entry.label}
|
||||
aria-pressed={selected}
|
||||
title={entry.label}
|
||||
onClick={() => setIcon(entry.key)}
|
||||
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>{submitLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||
]
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ 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
|
||||
@@ -256,12 +255,11 @@ 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) : buildMacProperty(device.mac)
|
||||
const properties = zigbee ? buildZigbeeProperties(device) : []
|
||||
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[],
|
||||
@@ -327,11 +325,10 @@ 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) : buildMacProperty(d.mac),
|
||||
properties: zigbee ? buildZigbeeProperties(d) : [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DesignModal } from '../DesignModal'
|
||||
import { DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
function renderModal(props: Partial<Parameters<typeof DesignModal>[0]> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
render(<DesignModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
describe('DesignModal', () => {
|
||||
it('creates with the typed name and default icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('submits the selected icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
|
||||
})
|
||||
|
||||
it('trims whitespace and blocks empty names', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
// Empty → submit disabled, no call.
|
||||
const submit = screen.getByRole('button', { name: 'Create' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
|
||||
fireEvent.click(submit)
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('prefills name and icon in edit mode', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { name: 'Existing', icon: 'server' },
|
||||
title: 'Edit Canvas',
|
||||
submitLabel: 'Save',
|
||||
})
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Existing')
|
||||
// The server icon button is pre-selected.
|
||||
expect(screen.getByRole('button', { name: 'Server' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Existing', icon: 'server' })
|
||||
})
|
||||
|
||||
it('submits on Enter from the name field', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
const input = screen.getByLabelText('Name')
|
||||
fireEvent.change(input, { target: { value: 'Quick' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('calls onClose from Cancel', () => {
|
||||
const { onClose, onSubmit } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,6 @@ 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: {
|
||||
@@ -70,7 +69,7 @@ const DEVICE_ZIGBEE = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
addNode: mockAddNode,
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||
// setState is used by injectAutoEdges
|
||||
@@ -175,34 +174,6 @@ 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())
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { designsApi, scanApi, settingsApi } from '@/api/client'
|
||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||
import type { Design } from '@/types'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
import {
|
||||
@@ -50,6 +54,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
|
||||
|
||||
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
||||
if (!designModal) return
|
||||
try {
|
||||
if (designModal.mode === 'create') {
|
||||
const res = await designsApi.create({ name: data.name, icon: data.icon })
|
||||
addDesign(res.data)
|
||||
} else if (designModal.design) {
|
||||
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
|
||||
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
|
||||
}
|
||||
setDesignModal(null)
|
||||
} catch {
|
||||
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
|
||||
}
|
||||
}, [designModal, addDesign, updateDesign])
|
||||
|
||||
const handleDesignDelete = useCallback(async (d: Design) => {
|
||||
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
||||
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
|
||||
try {
|
||||
await designsApi.delete(d.id)
|
||||
removeDesign(d.id)
|
||||
toast.success('Canvas deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete canvas')
|
||||
}
|
||||
}, [designs.length, removeDesign])
|
||||
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
@@ -88,6 +123,75 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<Logo size={28} showText={!collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Design Switcher */}
|
||||
{!collapsed && designs.length > 0 && (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border relative">
|
||||
<button
|
||||
onClick={() => setDesignSwitcherOpen((o) => !o)}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
|
||||
>
|
||||
{activeDesignId ? (() => {
|
||||
const active = designs.find((d) => d.id === activeDesignId)
|
||||
const Icon = resolveDesignIcon(active?.icon)
|
||||
return <><Icon size={14} className="shrink-0 text-[#00d4ff]" /><span className="truncate text-foreground">{active?.name ?? 'Select Canvas'}</span></>
|
||||
})() : <span className="text-muted-foreground">Select Canvas</span>}
|
||||
</button>
|
||||
{designSwitcherOpen && (
|
||||
<>
|
||||
{/* Overlay to close */}
|
||||
<div className="fixed inset-0 z-40" onClick={() => setDesignSwitcherOpen(false)} />
|
||||
<div className="absolute left-2 right-2 top-full mt-1 z-50 bg-[#21262d] border border-border rounded-md shadow-xl overflow-hidden">
|
||||
{designs.map((d) => {
|
||||
const Icon = resolveDesignIcon(d.icon)
|
||||
const isActive = d.id === activeDesignId
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`group flex items-center transition-colors ${
|
||||
isActive ? 'bg-[#00d4ff]/10 text-[#00d4ff]' : 'text-muted-foreground hover:bg-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setActiveDesign(d.id); setDesignSwitcherOpen(false) }}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-xs cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{d.name}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title="Edit canvas"
|
||||
onClick={() => { setDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
|
||||
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Delete ${d.name}`}
|
||||
title="Delete canvas"
|
||||
disabled={designs.length <= 1}
|
||||
onClick={() => handleDesignDelete(d)}
|
||||
className="shrink-0 p-1.5 pr-2 text-muted-foreground hover:text-[#f85149] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-border" />
|
||||
<button
|
||||
onClick={() => { setDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
<span>New Canvas</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Views */}
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
<SidebarItem
|
||||
@@ -190,6 +294,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
</div>
|
||||
|
||||
{!collapsed && <VersionBadge />}
|
||||
|
||||
<DesignModal
|
||||
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
|
||||
open={!!designModal}
|
||||
onClose={() => setDesignModal(null)}
|
||||
onSubmit={handleDesignSubmit}
|
||||
initial={designModal?.mode === 'edit' && designModal.design
|
||||
? { name: designModal.design.name, icon: designModal.design.icon ?? DEFAULT_DESIGN_ICON }
|
||||
: undefined}
|
||||
title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'}
|
||||
submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import type { Design } from '@/types'
|
||||
|
||||
function design(id: string, type: Design['design_type'] = 'network', name = id): Design {
|
||||
return { id, name, design_type: type, created_at: '', updated_at: '' }
|
||||
}
|
||||
|
||||
describe('designStore', () => {
|
||||
beforeEach(() => {
|
||||
useDesignStore.setState({ designs: [], activeDesignId: null, activeDesignType: null, loaded: false })
|
||||
})
|
||||
|
||||
it('starts empty and not loaded', () => {
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toEqual([])
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
expect(s.loaded).toBe(false)
|
||||
})
|
||||
|
||||
it('setDesigns selects the first design as active and marks loaded', () => {
|
||||
const a = design('a', 'network')
|
||||
const b = design('b', 'electrical')
|
||||
useDesignStore.getState().setDesigns([a, b])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toHaveLength(2)
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
expect(s.activeDesignType).toBe('network')
|
||||
expect(s.loaded).toBe(true)
|
||||
})
|
||||
|
||||
it('setDesigns preserves the active design when it is still present', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
// Re-list (e.g. after creating another design) — active id must not jump back to first.
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical'), design('c')])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('setDesigns falls back to first when the active design was removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
useDesignStore.getState().setDesigns([design('a')]) // 'b' deleted
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
expect(s.activeDesignType).toBe('network')
|
||||
})
|
||||
|
||||
it('setDesigns with an empty list clears the active selection', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setDesigns([])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
expect(s.loaded).toBe(true)
|
||||
})
|
||||
|
||||
it('setActiveDesign updates id and resolves type', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('setActiveDesign with an unknown id sets a null type', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setActiveDesign('missing')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('missing')
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
})
|
||||
|
||||
it('getActiveDesign returns the active design or null', () => {
|
||||
expect(useDesignStore.getState().getActiveDesign()).toBeNull()
|
||||
const b = design('b', 'electrical')
|
||||
useDesignStore.getState().setDesigns([design('a'), b])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
expect(useDesignStore.getState().getActiveDesign()).toEqual(b)
|
||||
})
|
||||
|
||||
it('addDesign appends and makes the new design active', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
const b = design('b', 'electrical', 'Power')
|
||||
useDesignStore.getState().addDesign(b)
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['a', 'b'])
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('updateDesign patches name and icon in place without touching others', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b')])
|
||||
useDesignStore.getState().updateDesign('a', { name: 'Renamed', icon: 'server' })
|
||||
const designs = useDesignStore.getState().designs
|
||||
expect(designs.find((d) => d.id === 'a')).toMatchObject({ name: 'Renamed', icon: 'server' })
|
||||
expect(designs.find((d) => d.id === 'b')!.name).toBe('b')
|
||||
})
|
||||
|
||||
it('removeDesign drops a non-active design and keeps the active one', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('b')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['a'])
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
})
|
||||
|
||||
it('removeDesign reassigns active to the first remaining when the active is removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('a')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['b'])
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('removeDesign clears active when the last design is removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('a')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toEqual([])
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Design, DesignType } from '@/types'
|
||||
|
||||
interface DesignState {
|
||||
designs: Design[]
|
||||
activeDesignId: string | null
|
||||
activeDesignType: DesignType | null
|
||||
loaded: boolean
|
||||
setDesigns: (designs: Design[]) => void
|
||||
setActiveDesign: (id: string) => void
|
||||
getActiveDesign: () => Design | null
|
||||
/** Append a new design and make it active. */
|
||||
addDesign: (design: Design) => void
|
||||
/** Patch an existing design in place (name/icon edits). */
|
||||
updateDesign: (id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>) => void
|
||||
/** Remove a design; if it was active, fall back to the first remaining one. */
|
||||
removeDesign: (id: string) => void
|
||||
}
|
||||
|
||||
export const useDesignStore = create<DesignState>((set, get) => ({
|
||||
designs: [],
|
||||
activeDesignId: null,
|
||||
activeDesignType: null,
|
||||
loaded: false,
|
||||
|
||||
setDesigns: (designs) =>
|
||||
set((state) => {
|
||||
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
|
||||
? state.activeDesignId
|
||||
: designs[0]?.id ?? null
|
||||
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
|
||||
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
|
||||
}),
|
||||
|
||||
setActiveDesign: (id) =>
|
||||
set((state) => {
|
||||
const design = state.designs.find((d) => d.id === id)
|
||||
return {
|
||||
activeDesignId: id,
|
||||
activeDesignType: design?.design_type ?? null,
|
||||
}
|
||||
}),
|
||||
|
||||
getActiveDesign: () => {
|
||||
const { designs, activeDesignId } = get()
|
||||
return designs.find((d) => d.id === activeDesignId) ?? null
|
||||
},
|
||||
|
||||
addDesign: (design) =>
|
||||
set((state) => ({
|
||||
designs: [...state.designs, design],
|
||||
activeDesignId: design.id,
|
||||
activeDesignType: design.design_type,
|
||||
})),
|
||||
|
||||
updateDesign: (id, patch) =>
|
||||
set((state) => ({
|
||||
designs: state.designs.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
})),
|
||||
|
||||
removeDesign: (id) =>
|
||||
set((state) => {
|
||||
const designs = state.designs.filter((d) => d.id !== id)
|
||||
if (state.activeDesignId !== id) return { designs }
|
||||
const next = designs[0] ?? null
|
||||
return {
|
||||
designs,
|
||||
activeDesignId: next?.id ?? null,
|
||||
activeDesignType: next?.design_type ?? null,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
@@ -1,3 +1,15 @@
|
||||
export type DesignType = 'network' | 'electrical'
|
||||
|
||||
export interface Design {
|
||||
id: string
|
||||
name: string
|
||||
design_type: DesignType
|
||||
/** Lucide icon key (see utils/designIcons). User-chosen; may be null on legacy rows. */
|
||||
icon?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'isp'
|
||||
| 'router'
|
||||
@@ -25,6 +37,20 @@ export type NodeType =
|
||||
| 'zigbee_coordinator'
|
||||
| 'zigbee_router'
|
||||
| 'zigbee_enddevice'
|
||||
| 'grid'
|
||||
| 'ups'
|
||||
| 'battery'
|
||||
| 'generator'
|
||||
| 'solar_panel'
|
||||
| 'inverter'
|
||||
| 'circuit_breaker'
|
||||
| 'contactor'
|
||||
| 'electrical_switch'
|
||||
| 'socket'
|
||||
| 'light'
|
||||
| 'meter'
|
||||
| 'transformer'
|
||||
| 'load'
|
||||
|
||||
export type TextPosition =
|
||||
| 'top-left'
|
||||
@@ -37,7 +63,7 @@ export type TextPosition =
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
|
||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre'
|
||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' | 'electrical'
|
||||
|
||||
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
||||
|
||||
@@ -159,6 +185,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
zigbee_coordinator: 'Zigbee Coordinator',
|
||||
zigbee_router: 'Zigbee Router',
|
||||
zigbee_enddevice: 'Zigbee End Device',
|
||||
grid: 'Grid Connection',
|
||||
ups: 'UPS',
|
||||
battery: 'Battery',
|
||||
generator: 'Generator',
|
||||
solar_panel: 'Solar Panel',
|
||||
inverter: 'Inverter',
|
||||
circuit_breaker: 'Circuit Breaker',
|
||||
contactor: 'Contactor',
|
||||
electrical_switch: 'Switch',
|
||||
socket: 'Socket / Outlet',
|
||||
light: 'Light Fixture',
|
||||
meter: 'Energy Meter',
|
||||
transformer: 'Transformer',
|
||||
load: 'Electrical Load',
|
||||
}
|
||||
|
||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||
@@ -176,6 +216,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
||||
virtual: 'Virtual',
|
||||
cluster: 'Cluster',
|
||||
fibre: 'Fibre',
|
||||
electrical: 'Electrical Wire',
|
||||
}
|
||||
|
||||
export interface NodeTypeStyle {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
|
||||
|
||||
describe('designIcons', () => {
|
||||
it('exposes a non-empty, unique-keyed icon set', () => {
|
||||
expect(DESIGN_ICONS.length).toBeGreaterThan(0)
|
||||
const keys = DESIGN_ICONS.map((e) => e.key)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
|
||||
it('default icon key is part of the set', () => {
|
||||
expect(DESIGN_ICONS.some((e) => e.key === DEFAULT_DESIGN_ICON)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveDesignIcon returns the matching component for a known key', () => {
|
||||
const entry = DESIGN_ICONS.find((e) => e.key === 'zap')!
|
||||
expect(resolveDesignIcon('zap')).toBe(entry.icon)
|
||||
})
|
||||
|
||||
it('resolveDesignIcon falls back to a component for unknown/empty keys', () => {
|
||||
const fallback = resolveDesignIcon(undefined)
|
||||
expect(typeof fallback).toBe('object')
|
||||
expect(resolveDesignIcon('does-not-exist')).toBe(fallback)
|
||||
expect(resolveDesignIcon(null)).toBe(fallback)
|
||||
expect(resolveDesignIcon('')).toBe(fallback)
|
||||
})
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
LayoutDashboard, Zap, Network, Server, HardDrive, Cpu, Wifi, Router,
|
||||
Database, Cloud, Home, Globe, Lightbulb, Factory, Plug, Boxes,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DesignIconEntry {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/** Curated icon set offered when creating/editing a canvas design. Keys are
|
||||
* stable strings persisted on `Design.icon`. */
|
||||
export const DESIGN_ICONS: DesignIconEntry[] = [
|
||||
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ key: 'network', label: 'Network', icon: Network },
|
||||
{ key: 'zap', label: 'Electrical', icon: Zap },
|
||||
{ key: 'server', label: 'Server', icon: Server },
|
||||
{ key: 'harddrive', label: 'Storage', icon: HardDrive },
|
||||
{ key: 'cpu', label: 'Compute', icon: Cpu },
|
||||
{ key: 'wifi', label: 'Wireless', icon: Wifi },
|
||||
{ key: 'router', label: 'Router', icon: Router },
|
||||
{ key: 'database', label: 'Database', icon: Database },
|
||||
{ key: 'cloud', label: 'Cloud', icon: Cloud },
|
||||
{ key: 'home', label: 'Home', icon: Home },
|
||||
{ key: 'globe', label: 'Internet', icon: Globe },
|
||||
{ key: 'lightbulb', label: 'Lighting', icon: Lightbulb },
|
||||
{ key: 'factory', label: 'Industrial', icon: Factory },
|
||||
{ key: 'plug', label: 'Power', icon: Plug },
|
||||
{ key: 'boxes', label: 'Cluster', icon: Boxes },
|
||||
]
|
||||
|
||||
export const DEFAULT_DESIGN_ICON = 'dashboard'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
DESIGN_ICONS.map((e) => [e.key, e.icon]),
|
||||
)
|
||||
|
||||
/** Resolve a persisted design icon key to a lucide component. Unknown/empty
|
||||
* keys fall back to the dashboard icon so the UI never breaks on legacy data. */
|
||||
export function resolveDesignIcon(key?: string | null): LucideIcon {
|
||||
return (key && ICON_MAP[key]) || LayoutDashboard
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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 }]
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Mail, MessageSquare, Phone,
|
||||
// Misc devices
|
||||
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
|
||||
// Electrical
|
||||
ToggleLeft,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -154,32 +156,46 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
)
|
||||
|
||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
laptop: Laptop,
|
||||
mobile: Smartphone,
|
||||
cpl: PlugZap,
|
||||
docker_host: Anchor,
|
||||
docker_container: Package,
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
laptop: Laptop,
|
||||
mobile: Smartphone,
|
||||
cpl: PlugZap,
|
||||
docker_host: Anchor,
|
||||
docker_container: Package,
|
||||
zigbee_coordinator: Radio,
|
||||
zigbee_router: Zap,
|
||||
zigbee_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
text: Type,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
text: Type,
|
||||
grid: Zap,
|
||||
ups: Power,
|
||||
battery: BatteryCharging,
|
||||
generator: Fan,
|
||||
solar_panel: Sun,
|
||||
inverter: Workflow,
|
||||
circuit_breaker: PlugZap,
|
||||
contactor: ToggleLeft,
|
||||
electrical_switch: ToggleLeft,
|
||||
socket: Plug,
|
||||
light: Lightbulb,
|
||||
meter: Gauge,
|
||||
transformer: CircleDot,
|
||||
load: Circle,
|
||||
}
|
||||
|
||||
/** Resolve the display icon for a node — custom_icon takes priority over type default.
|
||||
|
||||
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
text: { border: '#30363d', icon: '#e6edf3' },
|
||||
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
ups: { border: '#39d353', icon: '#39d353' },
|
||||
battery: { border: '#39d353', icon: '#39d353' },
|
||||
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||
contactor: { border: '#f85149', icon: '#f85149' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||
light: { border: '#e3b341', icon: '#e3b341' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||
load: { border: '#ec4899', icon: '#ec4899' },
|
||||
},
|
||||
nodeCardBackground: '#21262d',
|
||||
nodeIconBackground: '#161b22',
|
||||
@@ -87,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical:'#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
@@ -132,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
text: { border: '#404040', icon: '#ffffff' },
|
||||
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
ups: { border: '#39d353', icon: '#39d353' },
|
||||
battery: { border: '#39d353', icon: '#39d353' },
|
||||
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||
contactor: { border: '#f85149', icon: '#f85149' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||
light: { border: '#e3b341', icon: '#e3b341' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||
load: { border: '#ec4899', icon: '#ec4899' },
|
||||
},
|
||||
nodeCardBackground: '#0a0a0a',
|
||||
nodeIconBackground: '#111111',
|
||||
@@ -151,15 +180,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#6b7280',
|
||||
cluster: '#fb923c',
|
||||
fibre: '#06b6d4',
|
||||
electrical:'#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#22d3ee',
|
||||
edgeLabelBackground:'#111111',
|
||||
edgeLabelColor: '#666666',
|
||||
edgeLabelBorder: '#1c1c1e',
|
||||
canvasBackground: '#000000',
|
||||
canvasDotColor: '#1a1a1a',
|
||||
handleBackground: '#1c1c1e',
|
||||
handleBorder: '#444444',
|
||||
edgeLabelBackground:'#161b22',
|
||||
edgeLabelColor: '#9ca3af',
|
||||
edgeLabelBorder: '#374151',
|
||||
canvasBackground: '#030712',
|
||||
canvasDotColor: '#374151',
|
||||
handleBackground: '#374151',
|
||||
handleBorder: '#9ca3af',
|
||||
reactFlowColorMode: 'dark',
|
||||
},
|
||||
},
|
||||
@@ -196,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
text: { border: '#cbd5e1', icon: '#1f2328' },
|
||||
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
ups: { border: '#39d353', icon: '#39d353' },
|
||||
battery: { border: '#39d353', icon: '#39d353' },
|
||||
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||
contactor: { border: '#f85149', icon: '#f85149' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||
light: { border: '#e3b341', icon: '#e3b341' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||
load: { border: '#ec4899', icon: '#ec4899' },
|
||||
},
|
||||
nodeCardBackground: '#ffffff',
|
||||
nodeIconBackground: '#f0f6ff',
|
||||
@@ -215,15 +259,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#9ca3af',
|
||||
cluster: '#ea580c',
|
||||
fibre: '#0891b2',
|
||||
electrical:'#d97706',
|
||||
},
|
||||
edgeSelectedColor: '#0284c7',
|
||||
edgeLabelBackground:'#ffffff',
|
||||
edgeLabelColor: '#57606a',
|
||||
edgeLabelColor: '#6b7280',
|
||||
edgeLabelBorder: '#d0d7de',
|
||||
canvasBackground: '#f6f8fa',
|
||||
canvasDotColor: '#d0d7de',
|
||||
handleBackground: '#d0d7de',
|
||||
handleBorder: '#9ca3af',
|
||||
handleBorder: '#6b7280',
|
||||
reactFlowColorMode: 'light',
|
||||
},
|
||||
},
|
||||
@@ -260,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
text: { border: '#3a3a6a', icon: '#ffffff' },
|
||||
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
ups: { border: '#39d353', icon: '#39d353' },
|
||||
battery: { border: '#39d353', icon: '#39d353' },
|
||||
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||
contactor: { border: '#f85149', icon: '#f85149' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||
light: { border: '#e3b341', icon: '#e3b341' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||
load: { border: '#ec4899', icon: '#ec4899' },
|
||||
},
|
||||
nodeCardBackground: '#0f0f2a',
|
||||
nodeIconBackground: '#0a0a1a',
|
||||
@@ -279,6 +338,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8888cc',
|
||||
cluster: '#ff8800',
|
||||
fibre: '#00e5ff',
|
||||
electrical:'#ffff00',
|
||||
},
|
||||
edgeSelectedColor: '#00ffff',
|
||||
edgeLabelBackground:'#0a0a1a',
|
||||
@@ -324,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', icon: '#00ff41' },
|
||||
text: { border: '#003311', icon: '#00ff41' },
|
||||
grid: { border: '#cc6600', icon: '#cc6600' },
|
||||
ups: { border: '#00ff41', icon: '#00ff41' },
|
||||
battery: { border: '#00ff41', icon: '#00ff41' },
|
||||
generator: { border: '#ffcc00', icon: '#ffcc00' },
|
||||
solar_panel: { border: '#ffcc00', icon: '#ffcc00' },
|
||||
inverter: { border: '#aa00ff', icon: '#aa00ff' },
|
||||
circuit_breaker: { border: '#ff0033', icon: '#ff0033' },
|
||||
contactor: { border: '#ff0033', icon: '#ff0033' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#005500', icon: '#005500' },
|
||||
light: { border: '#ffcc00', icon: '#ffcc00' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#aa00ff', icon: '#aa00ff' },
|
||||
load: { border: '#ff69b4', icon: '#ff69b4' },
|
||||
},
|
||||
nodeCardBackground: '#001100',
|
||||
nodeIconBackground: '#002200',
|
||||
@@ -343,6 +417,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#004400',
|
||||
cluster: '#33ff66',
|
||||
fibre: '#00ffcc',
|
||||
electrical:'#66ff33',
|
||||
},
|
||||
edgeSelectedColor: '#00ff41',
|
||||
edgeLabelBackground:'#001100',
|
||||
@@ -388,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
text: { border: '#30363d', icon: '#e6edf3' },
|
||||
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||
ups: { border: '#39d353', icon: '#39d353' },
|
||||
battery: { border: '#39d353', icon: '#39d353' },
|
||||
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||
contactor: { border: '#f85149', icon: '#f85149' },
|
||||
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||
light: { border: '#e3b341', icon: '#e3b341' },
|
||||
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||
load: { border: '#ec4899', icon: '#ec4899' },
|
||||
},
|
||||
nodeCardBackground: '#21262d',
|
||||
nodeIconBackground: '#161b22',
|
||||
@@ -407,6 +496,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
|
||||
+78
-128
@@ -4,133 +4,87 @@ 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 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": {},
|
||||
}),
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
@@ -140,11 +94,7 @@ 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", "mac", "os", "status", "services",
|
||||
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
|
||||
"disk_gb", "parentId",
|
||||
}
|
||||
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
|
||||
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
||||
|
||||
def slim_node(n: dict) -> dict:
|
||||
|
||||
+1
-83
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.tools import TOOLS, _dispatch
|
||||
from app.tools import _dispatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,39 +32,6 @@ 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"})
|
||||
@@ -133,55 +100,6 @@ 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