feat: multi-design canvas system with electrical nodes/edges

Backend: New Design model + designs table; design_id FK on nodes, edges, canvas_state; migration seeds default 'Network Topology' design; full CRUD API for designs; canvas load/save accept design_id.

Frontend: designStore (Zustand), design switcher in Sidebar, design-aware canvas load/save, auto-save on design switch.

Electrical node types (14): grid, ups, battery, generator, solar_panel, inverter, circuit_breaker, contactor, electrical_switch, socket, light, meter, transformer, load — icons, registrations, accent colors in all 6 themes.

Electrical edge type: registered in edgeTypes, BASE_STYLES, edgeColors, all theme edgeColors, EDGE_DEFAULT_COLORS.

Bug fixes: data corruption on design switch (stale closure), race condition on save-then-load, missing Zap import, missing Electrical group in NodeModal, missing electrical entries in custom theme edgeColors, inline imports hoisted.
This commit is contained in:
Pranjal Joshi
2026-05-31 14:35:22 +05:30
parent 4ba04660c8
commit 46435605eb
19 changed files with 616 additions and 61 deletions
+37 -16
View File
@@ -1,13 +1,13 @@
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Edge, Node
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
@@ -16,10 +16,20 @@ router = APIRouter()
@router.get("", response_model=CanvasStateResponse)
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
nodes = (await db.execute(select(Node))).scalars().all()
edges = (await db.execute(select(Edge))).scalars().all()
state = await db.get(CanvasState, 1)
async def load_canvas(
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> CanvasStateResponse:
if design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
design_id = first.id if first else None
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
@@ -33,17 +43,24 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
async def save_canvas(
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> dict[str, bool]:
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:
return {"saved": False, "error": "No design found"}
incoming_node_ids = {n.id for n in body.nodes}
incoming_edge_ids = {e.id for e in body.edges}
# Delete nodes removed from canvas
existing_nodes = (await db.execute(select(Node))).scalars().all()
# Delete nodes removed from canvas (only within this design)
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
for node in existing_nodes:
if node.id not in incoming_node_ids:
await db.delete(node)
# Delete edges removed from canvas
existing_edges = (await db.execute(select(Edge))).scalars().all()
# Delete edges removed from canvas (only within this design)
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
for edge in existing_edges:
if edge.id not in incoming_edge_ids:
await db.delete(edge)
@@ -53,29 +70,33 @@ async def save_canvas(
# Upsert nodes
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
if db_node:
for field, value in node_data.model_dump().items():
for field, value in payload.items():
setattr(db_node, field, value)
else:
db.add(Node(**node_data.model_dump()))
db.add(Node(**payload))
# Upsert edges
for edge_data in body.edges:
db_edge = await db.get(Edge, edge_data.id)
payload = edge_data.model_dump()
payload["design_id"] = design_id
if db_edge:
for field, value in edge_data.model_dump().items():
for field, value in payload.items():
setattr(db_edge, field, value)
else:
db.add(Edge(**edge_data.model_dump()))
db.add(Edge(**payload))
# Upsert viewport + custom style
state = await db.get(CanvasState, 1)
state = await db.get(CanvasState, design_id)
if state:
state.viewport = body.viewport
state.custom_style = body.custom_style
state.saved_at = datetime.now(timezone.utc)
else:
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
await db.commit()
return {"saved": True}
+79
View File
@@ -0,0 +1,79 @@
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)
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
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()
+83 -3
View File
@@ -1,5 +1,7 @@
import json as _json
import logging
import shutil
import uuid as _uuid_mod
from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
@@ -168,12 +170,91 @@ async def init_db() -> None:
except OperationalError as exc:
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
# --- end Zigbee schema migrations -------------------------------------
# --- Electrical designs schema migrations -----------------------------
# Create designs table (idempotent)
await _try_migrate(
conn,
"CREATE TABLE IF NOT EXISTS designs ("
"id VARCHAR PRIMARY KEY,"
"name VARCHAR NOT NULL,"
"design_type VARCHAR NOT NULL DEFAULT 'network',"
"created_at DATETIME,"
"updated_at DATETIME"
")",
label="designs.table",
)
# 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 = (await row.fetchone())[0]
if count == 0:
await conn.exec_driver_sql(
"INSERT INTO designs (id, name, design_type, created_at, updated_at) "
"VALUES (?, 'Network Topology', 'network', 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 = await row2.fetchone()
_default_design_id = default[0] if default else _default_design_id
# Add design_id to nodes
await _try_migrate(
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="nodes.design_id",
)
# Assign existing nodes to default design
await conn.exec_driver_sql(
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Add design_id to edges
await _try_migrate(
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
label="edges.design_id",
)
# Assign existing edges to default design
await conn.exec_driver_sql(
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
)
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
try:
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
cols = info.fetchall()
has_design_id = any(c[1] == "design_id" for c in cols)
if not has_design_id:
logger.info("Migrating canvas_state: switching to design_id primary key")
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
await conn.exec_driver_sql(
"CREATE TABLE canvas_state_new ("
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
"viewport JSON,"
"custom_style JSON,"
"saved_at DATETIME"
")"
)
# Copy existing row(s), mapping id=1 to default design_id
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
for old in old_rows.fetchall():
cs_id, viewport, custom_style, saved_at = old
target_design = _default_design_id
await conn.exec_driver_sql(
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
"VALUES (?, ?, ?, ?)",
(target_design, viewport, custom_style, saved_at),
)
await conn.exec_driver_sql("DROP TABLE canvas_state")
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
except OperationalError as exc:
logger.warning("canvas_state migration failed: %s", exc)
# --- end Electrical designs schema migrations --------------------------
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
with suppress(OperationalError):
rows = await conn.exec_driver_sql(
@@ -192,7 +273,6 @@ async def init_db() -> None:
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
if disk_gb is not None:
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
import json as _json
await conn.exec_driver_sql(
"UPDATE nodes SET properties = ? WHERE id = ?",
(_json.dumps(props), node_id),
+13 -1
View File
@@ -16,12 +16,23 @@ def _uuid() -> str:
return str(uuid.uuid4())
class Design(Base):
__tablename__ = "designs"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
name: Mapped[str] = mapped_column(String, nullable=False)
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
class Node(Base):
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
@@ -61,6 +72,7 @@ class Edge(Base):
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
type: Mapped[str] = mapped_column(String, default="ethernet")
label: Mapped[str | None] = mapped_column(String)
vlan_id: Mapped[int | None] = mapped_column(Integer)
@@ -77,7 +89,7 @@ class Edge(Base):
class CanvasState(Base):
__tablename__ = "canvas_state"
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
@@ -51,6 +51,7 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
+1
View File
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
edges: list[EdgeSave] = []
viewport: dict[str, Any] = {}
custom_style: dict[str, Any] | None = None
design_id: str | None = None
class CanvasStateResponse(BaseModel):
+22
View File
@@ -0,0 +1,22 @@
from datetime import datetime
from pydantic import BaseModel
class DesignCreate(BaseModel):
name: str
design_type: str = "electrical"
class DesignUpdate(BaseModel):
name: str | None = None
class DesignResponse(BaseModel):
id: str
name: str
design_type: str
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}