Merge pull request #177 from pranjal-joshi/feat/electrical

feat: Multi Design Canvas + new node type for electrical devices
This commit is contained in:
Rémy
2026-06-04 13:20:22 +02:00
committed by GitHub
38 changed files with 1564 additions and 104 deletions
+42 -17
View File
@@ -1,13 +1,14 @@
import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, Query
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import get_db 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.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -16,10 +17,20 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse: async def load_canvas(
nodes = (await db.execute(select(Node))).scalars().all() design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
edges = (await db.execute(select(Edge))).scalars().all() db: AsyncSession = Depends(get_db),
state = await db.get(CanvasState, 1) _: 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} viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse( return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes], 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") @router.post("/save")
async def save_canvas( async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) 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_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges} incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas # Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node))).scalars().all() existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for node in existing_nodes: for node in existing_nodes:
if node.id not in incoming_node_ids: if node.id not in incoming_node_ids:
await db.delete(node) await db.delete(node)
# Delete edges removed from canvas # Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge))).scalars().all() existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for edge in existing_edges: for edge in existing_edges:
if edge.id not in incoming_edge_ids: if edge.id not in incoming_edge_ids:
await db.delete(edge) await db.delete(edge)
@@ -53,29 +74,33 @@ async def save_canvas(
# Upsert nodes # Upsert nodes
for node_data in body.nodes: for node_data in body.nodes:
db_node = await db.get(Node, node_data.id) db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node: if db_node:
for field, value in node_data.model_dump().items(): for field, value in payload.items():
setattr(db_node, field, value) setattr(db_node, field, value)
else: else:
db.add(Node(**node_data.model_dump())) db.add(Node(**payload))
# Upsert edges # Upsert edges
for edge_data in body.edges: for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id) db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge: if db_edge:
for field, value in edge_data.model_dump().items(): for field, value in payload.items():
setattr(db_edge, field, value) setattr(db_edge, field, value)
else: else:
db.add(Edge(**edge_data.model_dump())) db.add(Edge(**payload))
# Upsert viewport + custom style # Upsert viewport + custom style
state = await db.get(CanvasState, 1) state = await db.get(CanvasState, design_id)
if state: if state:
state.viewport = body.viewport state.viewport = body.viewport
state.custom_style = body.custom_style state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc) state.saved_at = datetime.now(timezone.utc)
else: 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() await db.commit()
return {"saved": True} return {"saved": True}
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
@router.get("", response_model=list[DesignResponse])
async def list_designs(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[DesignResponse]:
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
return [DesignResponse.model_validate(d) for d in designs]
@router.post("", response_model=DesignResponse, status_code=201)
async def create_design(
body: DesignCreate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
db.add(design)
await db.flush()
# Create empty canvas state for the new design
db.add(CanvasState(design_id=design.id))
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.put("/{design_id}", response_model=DesignResponse)
async def update_design(
design_id: str,
body: DesignUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
if body.name is not None:
design.name = body.name
if body.icon is not None:
design.icon = body.icon
await db.commit()
await db.refresh(design)
return DesignResponse.model_validate(design)
@router.delete("/{design_id}", status_code=204)
async def delete_design(
design_id: str,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> None:
design = await db.get(Design, design_id)
if not design:
raise HTTPException(404, "Design not found")
# Count remaining designs — prevent deleting the last one
count = (await db.execute(select(Design))).scalars().all()
if len(count) <= 1:
raise HTTPException(400, "Cannot delete the only design")
# Delete associated canvas state, edges, nodes
cs = await db.get(CanvasState, design_id)
if cs:
await db.delete(cs)
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for e in edges:
await db.delete(e)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for n in nodes:
await db.delete(n)
await db.delete(design)
await db.commit()
+11 -4
View File
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings from app.core.config import settings
from app.db.database import get_db 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.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse from app.schemas.nodes import NodeResponse
@@ -18,6 +18,7 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse) @router.get("", response_model=CanvasStateResponse)
async def liveview_canvas( async def liveview_canvas(
key: str | None = Query(default=None), 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), db: AsyncSession = Depends(get_db),
) -> CanvasStateResponse: ) -> CanvasStateResponse:
"""Read-only public canvas endpoint. """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): if not key or not hmac.compare_digest(key, settings.liveview_key):
raise HTTPException(status_code=403, detail="Invalid live view key") raise HTTPException(status_code=403, detail="Invalid live view key")
nodes = (await db.execute(select(Node))).scalars().all() if design_id is None:
edges = (await db.execute(select(Edge))).scalars().all() first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
state = await db.get(CanvasState, 1) 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} 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 custom_style: dict[str, Any] | None = state.custom_style if state else None
return CanvasStateResponse( return CanvasStateResponse(
+19 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.core.config import settings from app.core.config import settings
from app.db.database import AsyncSessionLocal, get_db 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.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.scanner import request_cancel, run_scan from app.services.scanner import request_cancel, run_scan
@@ -146,6 +146,10 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> 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( result = await db.execute(
select(PendingDevice).where( select(PendingDevice).where(
PendingDevice.id.in_(payload.device_ids), PendingDevice.id.in_(payload.device_ids),
@@ -173,6 +177,7 @@ async def bulk_approve_devices(
# Default to ping so the status checker actually polls the new node. # Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check). # Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_zigbee else ("ping" if device.ip else None), check_method="none" if is_zigbee else ("ping" if device.ip else None),
design_id=default_design_id,
) )
db.add(node) db.add(node)
created_nodes.append(node) created_nodes.append(node)
@@ -256,6 +261,12 @@ async def approve_device(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user), _: str = Depends(get_current_user),
) -> dict[str, Any]: ) -> 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) device = await db.get(PendingDevice, device_id)
if not device: if not device:
raise HTTPException(status_code=404, detail="Device not found") raise HTTPException(status_code=404, detail="Device not found")
@@ -280,6 +291,7 @@ async def approve_device(
) if _is_zigbee else merge_mac_property(node_data.properties, _mac), ) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if _is_zigbee else node_data.check_target, check_target=None if _is_zigbee else node_data.check_target,
design_id=node_design_id,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
@@ -361,12 +373,18 @@ async def _resolve_pending_links_for_ieee(
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs: if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
await db.delete(link) await db.delete(link)
continue 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( edge = Edge(
source=src_id, source=src_id,
target=tgt_id, target=tgt_id,
type="iot", type="iot",
source_handle="bottom", source_handle="bottom",
target_handle="top-t", target_handle="top-t",
design_id=edge_design_id,
) )
db.add(edge) db.add(edge)
await db.flush() await db.flush()
+6 -1
View File
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.database import AsyncSessionLocal, get_db 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.scan import ScanRunResponse
from app.schemas.zigbee import ( from app.schemas.zigbee import (
ZigbeeCoordinatorOut, ZigbeeCoordinatorOut,
@@ -138,6 +138,10 @@ async def _persist_pending_import(
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE. 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. 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_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False coordinator_existed = False
pending_created = 0 pending_created = 0
@@ -174,6 +178,7 @@ async def _persist_pending_import(
ieee_address=ieee, ieee_address=ieee,
services=[], services=[],
properties=props, properties=props,
design_id=default_design_id,
) )
db.add(node) db.add(node)
await db.flush() await db.flush()
+99 -5
View File
@@ -1,5 +1,7 @@
import json as _json
import logging import logging
import shutil import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import suppress from contextlib import suppress
from pathlib import Path from pathlib import Path
@@ -168,20 +170,113 @@ async def init_db() -> None:
except OperationalError as exc: except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc) logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations ------------------------------------- # --- 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): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError): with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") 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) # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError): with suppress(OperationalError):
rows = await conn.exec_driver_sql( rows = await conn.exec_driver_sql(
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
"FROM nodes WHERE properties IS NULL" "FROM nodes WHERE properties IS NULL"
) )
for row in rows.fetchall(): for r in rows.fetchall():
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
props = [] props = []
visible = bool(show_hardware) visible = bool(show_hardware)
if cpu_model: 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}) props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None: if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql( await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?", "UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id), (_json.dumps(props), node_id),
+14 -1
View File
@@ -16,12 +16,24 @@ def _uuid() -> str:
return str(uuid.uuid4()) 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): class Node(Base):
__tablename__ = "nodes" __tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False) type: Mapped[str] = mapped_column(String, nullable=False)
label: 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) hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String) ip: Mapped[str | None] = mapped_column(String)
mac: 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) id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: 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") type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String) label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer) vlan_id: Mapped[int | None] = mapped_column(Integer)
@@ -77,7 +90,7 @@ class Edge(Base):
class CanvasState(Base): class CanvasState(Base):
__tablename__ = "canvas_state" __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) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler 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(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"]) 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(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(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"]) app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"]) app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+1
View File
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
edges: list[EdgeSave] = [] edges: list[EdgeSave] = []
viewport: dict[str, Any] = {} viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None custom_style: dict[str, Any] | None = None
design_id: str | None = None
class CanvasStateResponse(BaseModel): class CanvasStateResponse(BaseModel):
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime
from pydantic import BaseModel
class DesignCreate(BaseModel):
name: str
icon: str = "dashboard"
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
# the chosen icon now drives presentation. Defaults to a generic canvas.
design_type: str = "network"
class DesignUpdate(BaseModel):
name: str | None = None
icon: str | None = None
class DesignResponse(BaseModel):
id: str
name: str
design_type: str
icon: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+1 -1
View File
@@ -26,7 +26,7 @@ class EdgeBase(BaseModel):
class EdgeCreate(EdgeBase): class EdgeCreate(EdgeBase):
pass design_id: str | None = None
class EdgeUpdate(BaseModel): class EdgeUpdate(BaseModel):
+3 -1
View File
@@ -34,7 +34,7 @@ class NodeBase(BaseModel):
class NodeCreate(NodeBase): class NodeCreate(NodeBase):
pass design_id: str | None = None
class NodeUpdate(BaseModel): class NodeUpdate(BaseModel):
@@ -68,6 +68,8 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase): class NodeResponse(NodeBase):
id: str id: str
design_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None last_seen: datetime | None = None
response_time_ms: int | None = None response_time_ms: int | None = None
created_at: datetime created_at: datetime
+165
View File
@@ -0,0 +1,165 @@
import uuid
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
def edge_payload(src, tgt, **kwargs):
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
assert res.status_code == 201, res.text
return res.json()
# ── auth ──────────────────────────────────────────────────────────────────────
async def test_list_designs_requires_auth(client: AsyncClient):
res = await client.get("/api/v1/designs")
assert res.status_code == 401
async def test_create_design_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/designs", json={"name": "X"})
assert res.status_code == 401
# ── list / create ─────────────────────────────────────────────────────────────
async def test_list_designs_empty(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/designs", headers=headers)
assert res.status_code == 200
assert res.json() == []
async def test_create_design_defaults(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Workshop")
assert design["name"] == "Workshop"
assert design["design_type"] == "network"
assert design["icon"] == "dashboard"
assert "id" in design and design["id"]
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Net", design_type="network")
assert design["design_type"] == "network"
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Power", icon="zap")
assert design["icon"] == "zap"
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="D", icon="dashboard")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
assert res.status_code == 200
assert res.json()["icon"] == "server"
# Name left untouched when only icon is sent.
assert res.json()["name"] == "D"
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old", icon="dashboard")
res = await client.put(
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
)
assert res.status_code == 200
body = res.json()
assert body["name"] == "New"
assert body["icon"] == "network"
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Has Canvas")
# Loading the new design returns an (empty) canvas without falling back to another design.
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
assert res.status_code == 200
body = res.json()
assert body["nodes"] == []
assert body["edges"] == []
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
a = await _create(client, headers, name="First")
b = await _create(client, headers, name="Second")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
ids = [d["id"] for d in listed]
assert ids == [a["id"], b["id"]]
# ── update ────────────────────────────────────────────────────────────────────
async def test_update_design_renames(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Old Name")
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
assert res.status_code == 200
assert res.json()["name"] == "New Name"
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
assert res.status_code == 404
# ── delete ────────────────────────────────────────────────────────────────────
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Only One")
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
assert res.status_code == 400
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
await _create(client, headers, name="Keep")
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
assert res.status_code == 404
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
keep = await _create(client, headers, name="Keep")
victim = await _create(client, headers, name="Victim")
# Populate the victim design with nodes + an edge via canvas save.
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
headers=headers,
)
assert save.status_code == 200
# Populate the kept design too, to prove scoping.
k1 = node_payload(label="K")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
headers=headers,
)
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
assert res.status_code == 204
# Victim gone from list.
listed = (await client.get("/api/v1/designs", headers=headers)).json()
assert [d["id"] for d in listed] == [keep["id"]]
# Kept design's node survives untouched.
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
assert len(kept_canvas["nodes"]) == 1
assert kept_canvas["nodes"][0]["label"] == "K"
+134
View File
@@ -0,0 +1,134 @@
"""Backward-compatibility tests for the legacy → multi-design migration.
Simulates a database created by a pre-"designs" version of the app and asserts
that running init_db() adopts all existing nodes/edges/canvas into a single
default "Network Topology" design with no data loss. The rest of the test suite
builds the *current* schema via create_all and never exercises this upgrade
path, so this file guards real users upgrading in place.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
@pytest.fixture
def legacy_engine(tmp_path, monkeypatch):
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
db_path = tmp_path / "legacy.db"
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setattr(database, "engine", engine)
return db_path, engine
async def _build_legacy_schema(engine) -> None:
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
async with engine.begin() as conn:
await conn.exec_driver_sql(
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
)
await conn.exec_driver_sql(
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
)
await conn.exec_driver_sql(
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
"custom_style JSON, saved_at DATETIME)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n1','server','Old Server','online','[]',10,20)"
)
await conn.exec_driver_sql(
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
)
await conn.exec_driver_sql(
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
)
await conn.exec_driver_sql(
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
)
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
# Exactly one seeded default design.
designs = (await conn.exec_driver_sql(
"SELECT id, name, design_type, icon FROM designs"
)).fetchall()
assert len(designs) == 1
did, name, dtype, icon = designs[0]
assert name == "Network Topology"
assert dtype == "network"
assert icon == "dashboard"
# Every legacy node adopted into the default design, data preserved.
nodes = (await conn.exec_driver_sql(
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
)).fetchall()
assert [(n[0], n[1], n[2]) for n in nodes] == [
("n1", "Old Server", "online"),
("n2", "Old Router", "offline"),
]
assert all(n[3] == did for n in nodes)
# Legacy edge adopted too.
edge = (await conn.exec_driver_sql(
"SELECT design_id FROM edges WHERE id='e1'"
)).fetchone()
assert edge[0] == did
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
# default design and the viewport survives.
cs = (await conn.exec_driver_sql(
"SELECT design_id, viewport FROM canvas_state"
)).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
assert "zoom" in (cs[0][1] or "")
finally:
await check.dispose()
await engine.dispose()
async def test_migration_is_idempotent(legacy_engine):
"""Running init_db twice must not duplicate the design or drop any data."""
db_path, engine = legacy_engine
await _build_legacy_schema(engine)
await database.init_db()
await database.init_db() # second boot — should be a no-op
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
assert len(designs) == 1
did = designs[0][0]
nodes = (await conn.exec_driver_sql(
"SELECT design_id FROM nodes"
)).fetchall()
assert len(nodes) == 2
assert all(n[0] == did for n in nodes)
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
assert len(cs) == 1
assert cs[0][0] == did
finally:
await check.dispose()
await engine.dispose()
+101 -28
View File
@@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { useThemeStore } from '@/stores/themeStore' import { useThemeStore } from '@/stores/themeStore'
import { canvasApi } from '@/api/client' import { canvasApi, designsApi } from '@/api/client'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -44,6 +45,7 @@ export default function App() {
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling() useStatusPolling()
@@ -70,29 +72,75 @@ export default function App() {
const [exportModalOpen, setExportModalOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope // Declare handleSave before the Ctrl+S effect so it is in scope.
const handleSave = useCallback(async () => { // 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 { try {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return return true
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) 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() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return true
} catch { } catch {
toast.error('Save failed') 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 // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
useEffect(() => { handleSaveRef.current = handleSave }, [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) // Load canvas on auth (or immediately in standalone mode)
useEffect(() => { useEffect(() => {
if (STANDALONE) { if (STANDALONE) {
@@ -112,28 +160,53 @@ export default function App() {
return return
} }
if (!isAuthenticated) return if (!isAuthenticated) return
canvasApi.load() loadDesignsAndCanvas()
.then((res) => { }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
const { nodes: apiNodes, edges: apiEdges } = res.data
if (apiNodes.length > 0) { // Reload canvas when active design changes (after initial load)
// Build a map of container mode nodes to know if children should be nested const initialLoadDone = useRef(false)
const proxmoxContainerMap = new Map<string, boolean>( const prevDesignRef = useRef<string | null>(null)
(apiNodes as ApiNode[]) // Set while we programmatically revert activeDesignId after a failed save, so
.filter((n) => n.type === 'group' || n.container_mode === true) // the re-entrant effect run skips save/load and just re-syncs the refs.
.map((n) => [n.id, true]) const revertingRef = useRef(false)
) useEffect(() => {
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) if (revertingRef.current) {
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) revertingRef.current = false
const savedTheme = res.data.viewport?.theme_id prevDesignRef.current = activeDesignId
if (savedTheme) setTheme(savedTheme) return
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) }
loadCanvas(rfNodes, rfEdges) if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
} else { const oldId = prevDesignRef.current
loadCanvas(demoNodes, demoEdges) // 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
.catch(() => loadCanvas(demoNodes, demoEdges)) if (oldId && oldId !== activeDesignId && oldStillExists) {
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // 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 // Keep refs for store actions so keydown handler is always up-to-date without re-registering
const undoRef = useRef(undo) const undoRef = useRef(undo)
+1 -1
View File
@@ -127,7 +127,7 @@ describe('api/client', () => {
it('canvasApi.load GETs /canvas', () => { it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load() mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas') expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
}) })
it('canvasApi.save POSTs to /canvas/save with payload', () => { it('canvasApi.save POSTs to /canvas/save with payload', () => {
+14 -1
View File
@@ -28,12 +28,16 @@ export const authApi = {
} }
export const canvasApi = { export const canvasApi = {
load: () => api.get('/canvas'), load: (design_id?: string) => {
const params = design_id ? { design_id } : {}
return api.get('/canvas', { params })
},
save: (payload: { save: (payload: {
nodes: object[] nodes: object[]
edges: object[] edges: object[]
viewport: object viewport: object
custom_style?: object | null custom_style?: object | null
design_id?: string | null
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
@@ -89,6 +93,15 @@ export const settingsApi = {
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), 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 = { export const zigbeeApi = {
testConnection: (data: { testConnection: (data: {
mqtt_host: string mqtt_host: string
+2
View File
@@ -158,6 +158,8 @@ function LiveViewCanvas() {
elementsSelectable={false} elementsSelectable={false}
panOnDrag panOnDrag
zoomOnScroll zoomOnScroll
minZoom={0.25}
maxZoom={2.5}
colorMode={theme.colors.reactFlowColorMode} colorMode={theme.colors.reactFlowColorMode}
connectionMode={ConnectionMode.Loose} connectionMode={ConnectionMode.Loose}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
@@ -5,9 +5,15 @@ import { useThemeStore } from '@/stores/themeStore'
// ── Mock heavy dependencies ──────────────────────────────────────────────── // ── Mock heavy dependencies ────────────────────────────────────────────────
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
let rfProps: Record<string, unknown> = {}
vi.mock('@xyflow/react', () => ({ vi.mock('@xyflow/react', () => ({
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>, ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
ReactFlow: () => <div data-testid="react-flow" />, ReactFlow: (props: Record<string, unknown>) => {
rfProps = props
return <div data-testid="react-flow" />
},
Background: () => null, Background: () => null,
Controls: () => null, Controls: () => null,
BackgroundVariant: { Dots: 'dots' }, BackgroundVariant: { Dots: 'dots' },
@@ -49,6 +55,7 @@ const canvasPayload = {
describe('LiveView (non-standalone)', () => { describe('LiveView (non-standalone)', () => {
beforeEach(() => { beforeEach(() => {
rfProps = {}
vi.mocked(liveviewApi.load).mockReset() vi.mocked(liveviewApi.load).mockReset()
useCanvasStore.setState({ nodes: [], edges: [] }) useCanvasStore.setState({ nodes: [], edges: [] })
}) })
@@ -114,6 +121,17 @@ describe('LiveView (non-standalone)', () => {
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key') expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
}) })
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
setSearch('?key=correct-key')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
render(<LiveView />)
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
// can't zoom out far enough to fit.
expect(rfProps.minZoom).toBe(0.25)
expect(rfProps.maxZoom).toBe(2.5)
})
it('loads nodes into the canvas store on success', async () => { it('loads nodes into the canvas store on success', async () => {
setSearch('?key=secret') setSearch('?key=secret')
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
@@ -8,4 +8,5 @@ export const edgeTypes = {
virtual: HomelableEdge, virtual: HomelableEdge,
cluster: HomelableEdge, cluster: HomelableEdge,
fibre: 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' }, virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` }, 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 const customColor = data?.custom_color as string | undefined
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
import { import {
Globe, Router, Network, Server, Layers, Box, Container, Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react' } from 'lucide-react'
import { BaseNode } from './BaseNode' import { BaseNode } from './BaseNode'
import type { NodeData } from '@/types' 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 ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} /> export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} /> 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 { ProxmoxGroupNode } from './ProxmoxGroupNode'
import { GroupRectNode } from './GroupRectNode' import { GroupRectNode } from './GroupRectNode'
import { GroupNode } from './GroupNode' import { GroupNode } from './GroupNode'
@@ -31,4 +39,18 @@ export const nodeTypes = {
zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_coordinator: ZigbeeCoordinatorNode,
zigbee_router: ZigbeeRouterNode, zigbee_router: ZigbeeRouterNode,
zigbee_enddevice: ZigbeeEndDeviceNode, 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', '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> = { const NODE_ICONS: Record<string, LucideIcon> = {
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
@@ -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: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, { 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'] }, { label: 'Generic', types: ['generic', 'groupRect'] },
] ]
@@ -3,7 +3,7 @@ import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network, Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
} from 'lucide-react' } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { scanApi } from '@/api/client' import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { toast } from 'sonner' import { toast } from 'sonner'
@@ -373,12 +373,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() } if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() } else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() } else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() } else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
// Enter confirms the bulk action for the current view: approving
// hidden devices would be wrong — they restore.
e.preventDefault()
if (statusFilter === 'hidden') handleBulkRestore()
else handleBulkApprove()
}
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler)
// statusFilter is included so Enter dispatches the correct bulk action
// (approve vs restore) even if the device list doesn't change on switch.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, selectMode, selectedIds, filtered]) }, [open, selectMode, selectedIds, filtered, statusFilter])
return ( return (
<> <>
@@ -408,9 +416,19 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
)} )}
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close"> {/* Route the close X through Base UI's DialogClose (same path as
outside-click) instead of a raw onClick the latter's synthetic
click was being dropped on Firefox/Windows. */}
<DialogClose
render={
<button
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
aria-label="Close"
/>
}
>
<X size={14} /> <X size={14} />
</button> </DialogClose>
</div> </div>
</div> </div>
</DialogHeader> </DialogHeader>
@@ -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()
})
})
@@ -99,6 +99,14 @@ describe('PendingDevicesModal', () => {
expect(screen.getByText('living-room-bulb')).toBeInTheDocument() expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
}) })
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
const onClose = vi.fn()
render(<PendingDevicesModal open onClose={onClose} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('shows source chip ZIGBEE for zigbee device', async () => { it('shows source chip ZIGBEE for zigbee device', async () => {
render(<PendingDevicesModal {...baseProps} />) render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -242,4 +250,25 @@ describe('PendingDevicesModal', () => {
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ })) fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a'])) await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
}) })
it('Enter confirms approve in pending select mode', 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.keyDown(window, { key: 'Enter' })
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkRestore).not.toHaveBeenCalled()
})
it('Enter restores (not approves) in hidden select mode', async () => {
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
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.keyDown(window, { key: 'Enter' })
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
expect(mockBulkApprove).not.toHaveBeenCalled()
})
}) })
+118 -2
View File
@@ -1,10 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react' 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 { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' 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 { toast } from 'sonner'
import { useLatestRelease } from '@/hooks/useLatestRelease' import { useLatestRelease } from '@/hooks/useLatestRelease'
import { import {
@@ -50,6 +54,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas') const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
const [prevForceView, setPrevForceView] = useState(forceView) const [prevForceView, setPrevForceView] = useState(forceView)
const logout = useAuthStore((s) => s.logout) 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. // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
if (forceView !== prevForceView) { if (forceView !== prevForceView) {
@@ -88,6 +123,75 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<Logo size={28} showText={!collapsed} /> <Logo size={28} showText={!collapsed} />
</div> </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 */} {/* Views */}
<nav className="flex flex-col gap-0.5 p-2"> <nav className="flex flex-col gap-0.5 p-2">
<SidebarItem <SidebarItem
@@ -190,6 +294,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
</div> </div>
{!collapsed && <VersionBadge />} {!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> </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()
})
})
+72
View File
@@ -0,0 +1,72 @@
import { create } from 'zustand'
import type { Design, DesignType } from '@/types'
interface DesignState {
designs: Design[]
activeDesignId: string | null
activeDesignType: DesignType | null
loaded: boolean
setDesigns: (designs: Design[]) => void
setActiveDesign: (id: string) => void
getActiveDesign: () => Design | null
/** Append a new design and make it active. */
addDesign: (design: Design) => void
/** Patch an existing design in place (name/icon edits). */
updateDesign: (id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>) => void
/** Remove a design; if it was active, fall back to the first remaining one. */
removeDesign: (id: string) => void
}
export const useDesignStore = create<DesignState>((set, get) => ({
designs: [],
activeDesignId: null,
activeDesignType: null,
loaded: false,
setDesigns: (designs) =>
set((state) => {
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
? state.activeDesignId
: designs[0]?.id ?? null
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
}),
setActiveDesign: (id) =>
set((state) => {
const design = state.designs.find((d) => d.id === id)
return {
activeDesignId: id,
activeDesignType: design?.design_type ?? null,
}
}),
getActiveDesign: () => {
const { designs, activeDesignId } = get()
return designs.find((d) => d.id === activeDesignId) ?? null
},
addDesign: (design) =>
set((state) => ({
designs: [...state.designs, design],
activeDesignId: design.id,
activeDesignType: design.design_type,
})),
updateDesign: (id, patch) =>
set((state) => ({
designs: state.designs.map((d) => (d.id === id ? { ...d, ...patch } : d)),
})),
removeDesign: (id) =>
set((state) => {
const designs = state.designs.filter((d) => d.id !== id)
if (state.activeDesignId !== id) return { designs }
const next = designs[0] ?? null
return {
designs,
activeDesignId: next?.id ?? null,
activeDesignType: next?.design_type ?? null,
}
}),
}))
+42 -1
View File
@@ -1,3 +1,15 @@
export type DesignType = 'network' | 'electrical'
export interface Design {
id: string
name: string
design_type: DesignType
/** Lucide icon key (see utils/designIcons). User-chosen; may be null on legacy rows. */
icon?: string | null
created_at: string
updated_at: string
}
export type NodeType = export type NodeType =
| 'isp' | 'isp'
| 'router' | 'router'
@@ -25,6 +37,20 @@ export type NodeType =
| 'zigbee_coordinator' | 'zigbee_coordinator'
| 'zigbee_router' | 'zigbee_router'
| 'zigbee_enddevice' | 'zigbee_enddevice'
| 'grid'
| 'ups'
| 'battery'
| 'generator'
| 'solar_panel'
| 'inverter'
| 'circuit_breaker'
| 'contactor'
| 'electrical_switch'
| 'socket'
| 'light'
| 'meter'
| 'transformer'
| 'load'
export type TextPosition = export type TextPosition =
| 'top-left' | 'top-left'
@@ -37,7 +63,7 @@ export type TextPosition =
| 'bottom-center' | 'bottom-center'
| 'bottom-right' | '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' export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
@@ -159,6 +185,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
zigbee_coordinator: 'Zigbee Coordinator', zigbee_coordinator: 'Zigbee Coordinator',
zigbee_router: 'Zigbee Router', zigbee_router: 'Zigbee Router',
zigbee_enddevice: 'Zigbee End Device', 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> = { export const STATUS_COLORS: Record<NodeStatus, string> = {
@@ -176,6 +216,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
virtual: 'Virtual', virtual: 'Virtual',
cluster: 'Cluster', cluster: 'Cluster',
fibre: 'Fibre', fibre: 'Fibre',
electrical: 'Electrical Wire',
} }
export interface NodeTypeStyle { 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)
})
})
+44
View File
@@ -0,0 +1,44 @@
import {
LayoutDashboard, Zap, Network, Server, HardDrive, Cpu, Wifi, Router,
Database, Cloud, Home, Globe, Lightbulb, Factory, Plug, Boxes,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
export interface DesignIconEntry {
key: string
label: string
icon: LucideIcon
}
/** Curated icon set offered when creating/editing a canvas design. Keys are
* stable strings persisted on `Design.icon`. */
export const DESIGN_ICONS: DesignIconEntry[] = [
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ key: 'network', label: 'Network', icon: Network },
{ key: 'zap', label: 'Electrical', icon: Zap },
{ key: 'server', label: 'Server', icon: Server },
{ key: 'harddrive', label: 'Storage', icon: HardDrive },
{ key: 'cpu', label: 'Compute', icon: Cpu },
{ key: 'wifi', label: 'Wireless', icon: Wifi },
{ key: 'router', label: 'Router', icon: Router },
{ key: 'database', label: 'Database', icon: Database },
{ key: 'cloud', label: 'Cloud', icon: Cloud },
{ key: 'home', label: 'Home', icon: Home },
{ key: 'globe', label: 'Internet', icon: Globe },
{ key: 'lightbulb', label: 'Lighting', icon: Lightbulb },
{ key: 'factory', label: 'Industrial', icon: Factory },
{ key: 'plug', label: 'Power', icon: Plug },
{ key: 'boxes', label: 'Cluster', icon: Boxes },
]
export const DEFAULT_DESIGN_ICON = 'dashboard'
const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
DESIGN_ICONS.map((e) => [e.key, e.icon]),
)
/** Resolve a persisted design icon key to a lucide component. Unknown/empty
* keys fall back to the dashboard icon so the UI never breaks on legacy data. */
export function resolveDesignIcon(key?: string | null): LucideIcon {
return (key && ICON_MAP[key]) || LayoutDashboard
}
+1
View File
@@ -8,4 +8,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical: '#e3b341',
} }
+38 -22
View File
@@ -25,6 +25,8 @@ import {
Mail, MessageSquare, Phone, Mail, MessageSquare, Phone,
// Misc devices // Misc devices
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type, Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
// Electrical
ToggleLeft,
} from 'lucide-react' } from 'lucide-react'
import type { LucideIcon } 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> = { export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
isp: Globe, isp: Globe,
router: Router, router: Router,
firewall: Flame, firewall: Flame,
switch: Network, switch: Network,
server: Server, server: Server,
proxmox: Layers, proxmox: Layers,
vm: Box, vm: Box,
lxc: Container, lxc: Container,
nas: HardDrive, nas: HardDrive,
iot: Cpu, iot: Cpu,
ap: Wifi, ap: Wifi,
camera: Cctv, camera: Cctv,
printer: Printer, printer: Printer,
computer: Monitor, computer: Monitor,
laptop: Laptop, laptop: Laptop,
mobile: Smartphone, mobile: Smartphone,
cpl: PlugZap, cpl: PlugZap,
docker_host: Anchor, docker_host: Anchor,
docker_container: Package, docker_container: Package,
zigbee_coordinator: Radio, zigbee_coordinator: Radio,
zigbee_router: Zap, zigbee_router: Zap,
zigbee_enddevice: Lightbulb, zigbee_enddevice: Lightbulb,
generic: Circle, generic: Circle,
group: Circle, group: Circle,
groupRect: Circle, groupRect: Circle,
text: Type, 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. /** Resolve the display icon for a node custom_icon takes priority over type default.
+99 -9
View File
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, 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', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -87,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',
@@ -132,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#22d3ee', icon: '#22d3ee' }, groupRect: { border: '#22d3ee', icon: '#22d3ee' },
group: { border: '#22d3ee', icon: '#22d3ee' }, group: { border: '#22d3ee', icon: '#22d3ee' },
text: { border: '#404040', icon: '#ffffff' }, 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', nodeCardBackground: '#0a0a0a',
nodeIconBackground: '#111111', nodeIconBackground: '#111111',
@@ -151,15 +180,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#6b7280', virtual: '#6b7280',
cluster: '#fb923c', cluster: '#fb923c',
fibre: '#06b6d4', fibre: '#06b6d4',
electrical:'#e3b341',
}, },
edgeSelectedColor: '#22d3ee', edgeSelectedColor: '#22d3ee',
edgeLabelBackground:'#111111', edgeLabelBackground:'#161b22',
edgeLabelColor: '#666666', edgeLabelColor: '#9ca3af',
edgeLabelBorder: '#1c1c1e', edgeLabelBorder: '#374151',
canvasBackground: '#000000', canvasBackground: '#030712',
canvasDotColor: '#1a1a1a', canvasDotColor: '#374151',
handleBackground: '#1c1c1e', handleBackground: '#374151',
handleBorder: '#444444', handleBorder: '#9ca3af',
reactFlowColorMode: 'dark', reactFlowColorMode: 'dark',
}, },
}, },
@@ -196,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#0284c7', icon: '#0284c7' }, groupRect: { border: '#0284c7', icon: '#0284c7' },
group: { border: '#0284c7', icon: '#0284c7' }, group: { border: '#0284c7', icon: '#0284c7' },
text: { border: '#cbd5e1', icon: '#1f2328' }, 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', nodeCardBackground: '#ffffff',
nodeIconBackground: '#f0f6ff', nodeIconBackground: '#f0f6ff',
@@ -215,15 +259,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#9ca3af', virtual: '#9ca3af',
cluster: '#ea580c', cluster: '#ea580c',
fibre: '#0891b2', fibre: '#0891b2',
electrical:'#d97706',
}, },
edgeSelectedColor: '#0284c7', edgeSelectedColor: '#0284c7',
edgeLabelBackground:'#ffffff', edgeLabelBackground:'#ffffff',
edgeLabelColor: '#57606a', edgeLabelColor: '#6b7280',
edgeLabelBorder: '#d0d7de', edgeLabelBorder: '#d0d7de',
canvasBackground: '#f6f8fa', canvasBackground: '#f6f8fa',
canvasDotColor: '#d0d7de', canvasDotColor: '#d0d7de',
handleBackground: '#d0d7de', handleBackground: '#d0d7de',
handleBorder: '#9ca3af', handleBorder: '#6b7280',
reactFlowColorMode: 'light', reactFlowColorMode: 'light',
}, },
}, },
@@ -260,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ffff', icon: '#00ffff' }, groupRect: { border: '#00ffff', icon: '#00ffff' },
group: { border: '#00ffff', icon: '#00ffff' }, group: { border: '#00ffff', icon: '#00ffff' },
text: { border: '#3a3a6a', icon: '#ffffff' }, 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', nodeCardBackground: '#0f0f2a',
nodeIconBackground: '#0a0a1a', nodeIconBackground: '#0a0a1a',
@@ -279,6 +338,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8888cc', virtual: '#8888cc',
cluster: '#ff8800', cluster: '#ff8800',
fibre: '#00e5ff', fibre: '#00e5ff',
electrical:'#ffff00',
}, },
edgeSelectedColor: '#00ffff', edgeSelectedColor: '#00ffff',
edgeLabelBackground:'#0a0a1a', edgeLabelBackground:'#0a0a1a',
@@ -324,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00ff41', icon: '#00ff41' }, groupRect: { border: '#00ff41', icon: '#00ff41' },
group: { border: '#00ff41', icon: '#00ff41' }, group: { border: '#00ff41', icon: '#00ff41' },
text: { border: '#003311', 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', nodeCardBackground: '#001100',
nodeIconBackground: '#002200', nodeIconBackground: '#002200',
@@ -343,6 +417,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#004400', virtual: '#004400',
cluster: '#33ff66', cluster: '#33ff66',
fibre: '#00ffcc', fibre: '#00ffcc',
electrical:'#66ff33',
}, },
edgeSelectedColor: '#00ff41', edgeSelectedColor: '#00ff41',
edgeLabelBackground:'#001100', edgeLabelBackground:'#001100',
@@ -388,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
groupRect: { border: '#00d4ff', icon: '#00d4ff' }, groupRect: { border: '#00d4ff', icon: '#00d4ff' },
group: { border: '#00d4ff', icon: '#00d4ff' }, group: { border: '#00d4ff', icon: '#00d4ff' },
text: { border: '#30363d', icon: '#e6edf3' }, 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', nodeCardBackground: '#21262d',
nodeIconBackground: '#161b22', nodeIconBackground: '#161b22',
@@ -407,6 +496,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
virtual: '#8b949e', virtual: '#8b949e',
cluster: '#ff6e00', cluster: '#ff6e00',
fibre: '#22d3ee', fibre: '#22d3ee',
electrical: '#e3b341',
}, },
edgeSelectedColor: '#00d4ff', edgeSelectedColor: '#00d4ff',
edgeLabelBackground:'#161b22', edgeLabelBackground:'#161b22',