diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index cc7b342..6742ca9 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -1,13 +1,14 @@ +import uuid from datetime import datetime, timezone from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.db.database import get_db -from app.db.models import CanvasState, Edge, Node +from app.db.models import CanvasState, Design, Edge, Node from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse from app.schemas.edges import EdgeResponse from app.schemas.nodes import NodeResponse @@ -16,10 +17,20 @@ router = APIRouter() @router.get("", response_model=CanvasStateResponse) -async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse: - nodes = (await db.execute(select(Node))).scalars().all() - edges = (await db.execute(select(Edge))).scalars().all() - state = await db.get(CanvasState, 1) +async def load_canvas( + design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"), + db: AsyncSession = Depends(get_db), + _: str = Depends(get_current_user), +) -> CanvasStateResponse: + if design_id is None: + first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + design_id = first.id if first else None + if design_id is None: + return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None) + + nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all() + edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all() + state = await db.get(CanvasState, design_id) viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} return CanvasStateResponse( nodes=[NodeResponse.model_validate(n) for n in nodes], @@ -32,18 +43,28 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c @router.post("/save") async def save_canvas( body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user) -) -> dict[str, bool]: +) -> dict[str, bool | str]: + design_id = body.design_id + if design_id is None: + first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + design_id = first.id if first else None + if design_id is None: + new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network") + db.add(new_design) + await db.flush() + design_id = new_design.id + incoming_node_ids = {n.id for n in body.nodes} incoming_edge_ids = {e.id for e in body.edges} - # Delete nodes removed from canvas - existing_nodes = (await db.execute(select(Node))).scalars().all() + # Delete nodes removed from canvas (only within this design) + existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all() for node in existing_nodes: if node.id not in incoming_node_ids: await db.delete(node) - # Delete edges removed from canvas - existing_edges = (await db.execute(select(Edge))).scalars().all() + # Delete edges removed from canvas (only within this design) + existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all() for edge in existing_edges: if edge.id not in incoming_edge_ids: await db.delete(edge) @@ -53,29 +74,33 @@ async def save_canvas( # Upsert nodes for node_data in body.nodes: db_node = await db.get(Node, node_data.id) + payload = node_data.model_dump() + payload["design_id"] = design_id if db_node: - for field, value in node_data.model_dump().items(): + for field, value in payload.items(): setattr(db_node, field, value) else: - db.add(Node(**node_data.model_dump())) + db.add(Node(**payload)) # Upsert edges for edge_data in body.edges: db_edge = await db.get(Edge, edge_data.id) + payload = edge_data.model_dump() + payload["design_id"] = design_id if db_edge: - for field, value in edge_data.model_dump().items(): + for field, value in payload.items(): setattr(db_edge, field, value) else: - db.add(Edge(**edge_data.model_dump())) + db.add(Edge(**payload)) # Upsert viewport + custom style - state = await db.get(CanvasState, 1) + state = await db.get(CanvasState, design_id) if state: state.viewport = body.viewport state.custom_style = body.custom_style state.saved_at = datetime.now(timezone.utc) else: - db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style)) + db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style)) await db.commit() return {"saved": True} diff --git a/backend/app/api/routes/designs.py b/backend/app/api/routes/designs.py new file mode 100644 index 0000000..bed6d9e --- /dev/null +++ b/backend/app/api/routes/designs.py @@ -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() diff --git a/backend/app/api/routes/liveview.py b/backend/app/api/routes/liveview.py index c6cd0f1..137b2b9 100644 --- a/backend/app/api/routes/liveview.py +++ b/backend/app/api/routes/liveview.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings from app.db.database import get_db -from app.db.models import CanvasState, Edge, Node +from app.db.models import CanvasState, Design, Edge, Node from app.schemas.canvas import CanvasStateResponse from app.schemas.edges import EdgeResponse from app.schemas.nodes import NodeResponse @@ -18,6 +18,7 @@ router = APIRouter() @router.get("", response_model=CanvasStateResponse) async def liveview_canvas( key: str | None = Query(default=None), + design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"), db: AsyncSession = Depends(get_db), ) -> CanvasStateResponse: """Read-only public canvas endpoint. @@ -30,9 +31,15 @@ async def liveview_canvas( if not key or not hmac.compare_digest(key, settings.liveview_key): raise HTTPException(status_code=403, detail="Invalid live view key") - nodes = (await db.execute(select(Node))).scalars().all() - edges = (await db.execute(select(Edge))).scalars().all() - state = await db.get(CanvasState, 1) + if design_id is None: + first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + design_id = first.id if first else None + if design_id is None: + return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None) + + nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all() + edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all() + state = await db.get(CanvasState, design_id) viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1} custom_style: dict[str, Any] | None = state.custom_style if state else None return CanvasStateResponse( diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py index 9b1d8d2..390c86a 100644 --- a/backend/app/api/routes/scan.py +++ b/backend/app/api/routes/scan.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.core.config import settings from app.db.database import AsyncSessionLocal, get_db -from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun +from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.nodes import NodeCreate from app.schemas.scan import PendingDeviceResponse, ScanRunResponse from app.services.scanner import request_cancel, run_scan @@ -146,6 +146,10 @@ async def bulk_approve_devices( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> dict[str, Any]: + # Determine target design (use first design as fallback) + first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + default_design_id = first_design.id if first_design else None + result = await db.execute( select(PendingDevice).where( PendingDevice.id.in_(payload.device_ids), @@ -173,6 +177,7 @@ async def bulk_approve_devices( # Default to ping so the status checker actually polls the new node. # Without this the scheduler skips it (check_method NULL → no check). check_method="none" if is_zigbee else ("ping" if device.ip else None), + design_id=default_design_id, ) db.add(node) created_nodes.append(node) @@ -256,6 +261,12 @@ async def approve_device( db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user), ) -> dict[str, Any]: + # Determine target design + node_design_id = node_data.design_id + if node_design_id is None: + first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + node_design_id = first.id if first else None + device = await db.get(PendingDevice, device_id) if not device: raise HTTPException(status_code=404, detail="Device not found") @@ -280,6 +291,7 @@ async def approve_device( ) if _is_zigbee else merge_mac_property(node_data.properties, _mac), check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)), check_target=None if _is_zigbee else node_data.check_target, + design_id=node_design_id, ) db.add(node) 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: await db.delete(link) continue + # Use the source node's design_id for the edge + edge_design_id = self_node.design_id if self_node else None + if edge_design_id is None: + first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + edge_design_id = first.id if first else None edge = Edge( source=src_id, target=tgt_id, type="iot", source_handle="bottom", target_handle="top-t", + design_id=edge_design_id, ) db.add(edge) await db.flush() diff --git a/backend/app/api/routes/zigbee.py b/backend/app/api/routes/zigbee.py index 9a6e1c9..9e69af1 100644 --- a/backend/app/api/routes/zigbee.py +++ b/backend/app/api/routes/zigbee.py @@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.api.deps import get_current_user from app.db.database import AsyncSessionLocal, get_db -from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun +from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun from app.schemas.scan import ScanRunResponse from app.schemas.zigbee import ( ZigbeeCoordinatorOut, @@ -138,6 +138,10 @@ async def _persist_pending_import( Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE. All zigbee-source links are wiped and re-inserted from the new map. """ + # Determine target design (use first design as fallback) + first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar() + default_design_id = first_design.id if first_design else None + coordinator_out: ZigbeeCoordinatorOut | None = None coordinator_existed = False pending_created = 0 @@ -174,6 +178,7 @@ async def _persist_pending_import( ieee_address=ieee, services=[], properties=props, + design_id=default_design_id, ) db.add(node) await db.flush() diff --git a/backend/app/db/database.py b/backend/app/db/database.py index e0eca20..3da2212 100644 --- a/backend/app/db/database.py +++ b/backend/app/db/database.py @@ -1,5 +1,7 @@ +import json as _json import logging import shutil +import uuid as _uuid_mod from collections.abc import AsyncGenerator from contextlib import suppress from pathlib import Path @@ -168,20 +170,113 @@ async def init_db() -> None: except OperationalError as exc: logger.warning("pending_devices ip-nullable rebuild failed: %s", exc) # --- end Zigbee schema migrations ------------------------------------- + # --- Electrical designs schema migrations ----------------------------- + # Create designs table (idempotent) + await _try_migrate( + conn, + "CREATE TABLE IF NOT EXISTS designs (" + "id VARCHAR PRIMARY KEY," + "name VARCHAR NOT NULL," + "design_type VARCHAR NOT NULL DEFAULT 'network'," + "created_at DATETIME," + "updated_at DATETIME" + ")", + label="designs.table", + ) + # Add user-chosen icon to designs (idempotent), then backfill existing rows + # so legacy designs keep a sensible icon based on their original type. + await _try_migrate( + conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon", + ) + with suppress(OperationalError): + await conn.exec_driver_sql( + "UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'" + ) + with suppress(OperationalError): + await conn.exec_driver_sql( + "UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL" + ) + # Seed default Network Topology design if designs table is empty + _default_design_id = str(_uuid_mod.uuid4()) + row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs") + count_row = row.fetchone() + count = count_row[0] if count_row else 0 + if count == 0: + await conn.exec_driver_sql( + "INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) " + "VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))", + (_default_design_id,), + ) + else: + row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1") + default = row2.fetchone() + _default_design_id = default[0] if default else _default_design_id + + # Add design_id to nodes + await _try_migrate( + conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)", + label="nodes.design_id", + ) + # Assign existing nodes to default design + await conn.exec_driver_sql( + "UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,), + ) + + # Add design_id to edges + await _try_migrate( + conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)", + label="edges.design_id", + ) + # Assign existing edges to default design + await conn.exec_driver_sql( + "UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,), + ) + + # Migrate canvas_state from id=1 to design_id PK (SQLite rebuild) + try: + info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)") + cols = info.fetchall() + has_design_id = any(c[1] == "design_id" for c in cols) + if not has_design_id: + logger.info("Migrating canvas_state: switching to design_id primary key") + await conn.exec_driver_sql("PRAGMA foreign_keys = OFF") + await conn.exec_driver_sql( + "CREATE TABLE canvas_state_new (" + "design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE," + "viewport JSON," + "custom_style JSON," + "saved_at DATETIME" + ")" + ) + # Copy existing row(s), mapping id=1 to default design_id + old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state") + for old in old_rows.fetchall(): + cs_id, viewport, custom_style, saved_at = old + target_design = _default_design_id + await conn.exec_driver_sql( + "INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) " + "VALUES (?, ?, ?, ?)", + (target_design, viewport, custom_style, saved_at), + ) + await conn.exec_driver_sql("DROP TABLE canvas_state") + await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state") + await conn.exec_driver_sql("PRAGMA foreign_keys = ON") + except OperationalError as exc: + logger.warning("canvas_state migration failed: %s", exc) + # --- end Electrical designs schema migrations -------------------------- + with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON") with suppress(OperationalError): await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON") - with suppress(OperationalError): - await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON") # Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL) with suppress(OperationalError): rows = await conn.exec_driver_sql( "SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware " "FROM nodes WHERE properties IS NULL" ) - for row in rows.fetchall(): - node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row + for r in rows.fetchall(): + node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r props = [] visible = bool(show_hardware) if cpu_model: @@ -192,7 +287,6 @@ async def init_db() -> None: props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible}) if disk_gb is not None: props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible}) - import json as _json await conn.exec_driver_sql( "UPDATE nodes SET properties = ? WHERE id = ?", (_json.dumps(props), node_id), diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 203c36f..caccdc0 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -16,12 +16,24 @@ def _uuid() -> str: return str(uuid.uuid4()) +class Design(Base): + __tablename__ = "designs" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) + name: Mapped[str] = mapped_column(String, nullable=False) + design_type: Mapped[str] = mapped_column(String, nullable=False, default="network") + icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now) + + class Node(Base): __tablename__ = "nodes" id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) type: Mapped[str] = mapped_column(String, nullable=False) label: Mapped[str] = mapped_column(String, nullable=False) + design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True) hostname: Mapped[str | None] = mapped_column(String) ip: Mapped[str | None] = mapped_column(String) mac: Mapped[str | None] = mapped_column(String) @@ -61,6 +73,7 @@ class Edge(Base): id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid) source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE")) + design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True) type: Mapped[str] = mapped_column(String, default="ethernet") label: Mapped[str | None] = mapped_column(String) vlan_id: Mapped[int | None] = mapped_column(Integer) @@ -77,7 +90,7 @@ class Edge(Base): class CanvasState(Base): __tablename__ = "canvas_state" - id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True) viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/main.py b/backend/app/main.py index 8f0952f..0c0e8f7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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"]) diff --git a/backend/app/schemas/canvas.py b/backend/app/schemas/canvas.py index 0715c4d..6e8b9f0 100644 --- a/backend/app/schemas/canvas.py +++ b/backend/app/schemas/canvas.py @@ -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): diff --git a/backend/app/schemas/designs.py b/backend/app/schemas/designs.py new file mode 100644 index 0000000..32ae107 --- /dev/null +++ b/backend/app/schemas/designs.py @@ -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} diff --git a/backend/app/schemas/edges.py b/backend/app/schemas/edges.py index 8baa45b..05db453 100644 --- a/backend/app/schemas/edges.py +++ b/backend/app/schemas/edges.py @@ -26,7 +26,7 @@ class EdgeBase(BaseModel): class EdgeCreate(EdgeBase): - pass + design_id: str | None = None class EdgeUpdate(BaseModel): diff --git a/backend/app/schemas/nodes.py b/backend/app/schemas/nodes.py index a7f03e5..5683486 100644 --- a/backend/app/schemas/nodes.py +++ b/backend/app/schemas/nodes.py @@ -34,7 +34,7 @@ class NodeBase(BaseModel): class NodeCreate(NodeBase): - pass + design_id: str | None = None class NodeUpdate(BaseModel): @@ -68,6 +68,8 @@ class NodeUpdate(BaseModel): class NodeResponse(NodeBase): id: str + design_id: str | None = None + ieee_address: str | None = None last_seen: datetime | None = None response_time_ms: int | None = None created_at: datetime diff --git a/backend/tests/test_designs.py b/backend/tests/test_designs.py new file mode 100644 index 0000000..f70dbeb --- /dev/null +++ b/backend/tests/test_designs.py @@ -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" diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py new file mode 100644 index 0000000..fe225f9 --- /dev/null +++ b/backend/tests/test_migrations.py @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 073b7b9..7e4d800 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal' import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { useCanvasStore } from '@/stores/canvasStore' +import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' -import { canvasApi } from '@/api/client' +import { canvasApi, designsApi } from '@/api/client' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' import type { NodeData, EdgeData, CustomStyleDef } from '@/types' @@ -44,6 +45,7 @@ export default function App() { const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() + const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore() useStatusPolling() @@ -70,29 +72,75 @@ export default function App() { const [exportModalOpen, setExportModalOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) - // Declare handleSave before the Ctrl+S effect so it is in scope - const handleSave = useCallback(async () => { + // Declare handleSave before the Ctrl+S effect so it is in scope. + // Returns true on success, false on failure — the design-switch effect relies + // on this to avoid loading (and clobbering) the canvas when a save fails. + const handleSave = useCallback(async (designIdOverride?: string): Promise => { try { + const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() toast.success('Canvas saved') - return + return true } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle }) + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') + return true } catch { toast.error('Save failed') + return false } - }, [nodes, edges, markSaved, activeTheme, customStyle]) + }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) + const loadCanvasFromApi = useCallback(async (designId?: string) => { + try { + const res = await canvasApi.load(designId) + const { nodes: apiNodes, edges: apiEdges } = res.data + if (apiNodes.length > 0) { + const proxmoxContainerMap = new Map( + (apiNodes as ApiNode[]) + .filter((n) => n.type === 'group' || n.container_mode === true) + .map((n) => [n.id, true]) + ) + const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) + const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) + const savedTheme = res.data.viewport?.theme_id + if (savedTheme) setTheme(savedTheme) + if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + loadCanvas(rfNodes, rfEdges) + } else { + loadCanvas(demoNodes, demoEdges) + } + } catch { + loadCanvas(demoNodes, demoEdges) + } + }, [loadCanvas, setTheme, setCustomStyle]) + + const loadDesignsAndCanvas = useCallback(async () => { + if (STANDALONE) return + try { + const res = await designsApi.list() + const loadedDesigns = res.data + setDesigns(loadedDesigns) + const targetId = activeDesignId ?? loadedDesigns[0]?.id + if (targetId) { + setActiveDesign(targetId) + await loadCanvasFromApi(targetId) + } + } catch { + // If API fails (e.g. fresh DB with no designs), fall back to demo data + loadCanvas(demoNodes, demoEdges) + } + }, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas]) + // Load canvas on auth (or immediately in standalone mode) useEffect(() => { if (STANDALONE) { @@ -112,28 +160,53 @@ export default function App() { return } if (!isAuthenticated) return - canvasApi.load() - .then((res) => { - const { nodes: apiNodes, edges: apiEdges } = res.data - if (apiNodes.length > 0) { - // Build a map of container mode nodes to know if children should be nested - const proxmoxContainerMap = new Map( - (apiNodes as ApiNode[]) - .filter((n) => n.type === 'group' || n.container_mode === true) - .map((n) => [n.id, true]) - ) - const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)) - const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge) - const savedTheme = res.data.viewport?.theme_id - if (savedTheme) setTheme(savedTheme) - if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) - loadCanvas(rfNodes, rfEdges) - } else { - loadCanvas(demoNodes, demoEdges) - } - }) - .catch(() => loadCanvas(demoNodes, demoEdges)) - }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) + loadDesignsAndCanvas() + }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change + + // Reload canvas when active design changes (after initial load) + const initialLoadDone = useRef(false) + const prevDesignRef = useRef(null) + // Set while we programmatically revert activeDesignId after a failed save, so + // the re-entrant effect run skips save/load and just re-syncs the refs. + const revertingRef = useRef(false) + useEffect(() => { + if (revertingRef.current) { + revertingRef.current = false + prevDesignRef.current = activeDesignId + return + } + if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) { + const oldId = prevDesignRef.current + // If the previous design was deleted (no longer in the list), don't try to + // save into it — just load the newly-selected design. + const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false + if (oldId && oldId !== activeDesignId && oldStillExists) { + // Save current (old) canvas data under the old design ID before switching. + // We call handleSave directly (not via ref) so it runs in this effect's + // closure where activeDesignId is already the NEW value — the override + // ensures data is stored under the correct design_id. + const targetId = activeDesignId + handleSave(oldId).then((ok) => { + if (ok) { + loadCanvasFromApi(targetId) + } else { + // Save failed: don't load the new design — that would overwrite the + // unsaved in-memory canvas. Revert the selection back to the old + // design so the UI matches the data still on screen. + toast.error('Switch cancelled — unsaved changes kept') + revertingRef.current = true + setActiveDesign(oldId) + } + }) + } else { + loadCanvasFromApi(activeDesignId) + } + } + if (activeDesignId) { + prevDesignRef.current = activeDesignId + initialLoadDone.current = true + } + }, [activeDesignId]) // Keep refs for store actions so keydown handler is always up-to-date without re-registering const undoRef = useRef(undo) diff --git a/frontend/src/api/__tests__/client.test.ts b/frontend/src/api/__tests__/client.test.ts index 800cd08..fa8f2ff 100644 --- a/frontend/src/api/__tests__/client.test.ts +++ b/frontend/src/api/__tests__/client.test.ts @@ -127,7 +127,7 @@ describe('api/client', () => { it('canvasApi.load GETs /canvas', () => { mod.canvasApi.load() - expect(api.get).toHaveBeenCalledWith('/canvas') + expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({})) }) it('canvasApi.save POSTs to /canvas/save with payload', () => { diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1c4485a..fb8eb34 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,12 +28,16 @@ export const authApi = { } export const canvasApi = { - load: () => api.get('/canvas'), + load: (design_id?: string) => { + const params = design_id ? { design_id } : {} + return api.get('/canvas', { params }) + }, save: (payload: { nodes: object[] edges: object[] viewport: object custom_style?: object | null + design_id?: string | null }) => api.post('/canvas/save', payload), } @@ -89,6 +93,15 @@ export const settingsApi = { save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data), } +export const designsApi = { + list: () => api.get('/designs'), + create: (data: { name: string; icon?: string; design_type?: string }) => + api.post('/designs', data), + update: (id: string, data: { name?: string; icon?: string }) => + api.put(`/designs/${id}`, data), + delete: (id: string) => api.delete(`/designs/${id}`), +} + export const zigbeeApi = { testConnection: (data: { mqtt_host: string diff --git a/frontend/src/components/LiveView.tsx b/frontend/src/components/LiveView.tsx index 381a7aa..e366a61 100644 --- a/frontend/src/components/LiveView.tsx +++ b/frontend/src/components/LiveView.tsx @@ -158,6 +158,8 @@ function LiveViewCanvas() { elementsSelectable={false} panOnDrag zoomOnScroll + minZoom={0.25} + maxZoom={2.5} colorMode={theme.colors.reactFlowColorMode} connectionMode={ConnectionMode.Loose} onNodeClick={onNodeClick} diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index 89c6b39..c77ac33 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -5,9 +5,15 @@ import { useThemeStore } from '@/stores/themeStore' // ── Mock heavy dependencies ──────────────────────────────────────────────── +// Capture props passed to ReactFlow so we can assert zoom bounds etc. +let rfProps: Record = {} + vi.mock('@xyflow/react', () => ({ ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, - ReactFlow: () =>
, + ReactFlow: (props: Record) => { + rfProps = props + return
+ }, Background: () => null, Controls: () => null, BackgroundVariant: { Dots: 'dots' }, @@ -49,6 +55,7 @@ const canvasPayload = { describe('LiveView (non-standalone)', () => { beforeEach(() => { + rfProps = {} vi.mocked(liveviewApi.load).mockReset() useCanvasStore.setState({ nodes: [], edges: [] }) }) @@ -114,6 +121,17 @@ describe('LiveView (non-standalone)', () => { 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() + 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 () => { setSearch('?key=secret') vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never) diff --git a/frontend/src/components/canvas/edges/edgeTypes.ts b/frontend/src/components/canvas/edges/edgeTypes.ts index ef46d02..160900b 100644 --- a/frontend/src/components/canvas/edges/edgeTypes.ts +++ b/frontend/src/components/canvas/edges/edgeTypes.ts @@ -8,4 +8,5 @@ export const edgeTypes = { virtual: HomelableEdge, cluster: HomelableEdge, fibre: HomelableEdge, + electrical: HomelableEdge, } diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 7393905..54f64b9 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -324,6 +324,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' }, cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' }, fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` }, + electrical: { stroke: edgeColors.electrical, strokeWidth: 2 }, } const customColor = data?.custom_color as string | undefined diff --git a/frontend/src/components/canvas/nodes/index.tsx b/frontend/src/components/canvas/nodes/index.tsx index 1cb114b..1af4afd 100644 --- a/frontend/src/components/canvas/nodes/index.tsx +++ b/frontend/src/components/canvas/nodes/index.tsx @@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react' import { Globe, Router, Network, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, + Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap, } from 'lucide-react' import { BaseNode } from './BaseNode' import type { NodeData } from '@/types' @@ -32,3 +33,19 @@ export const GenericNode = (props: N) => export const ZigbeeCoordinatorNode = (props: N) => export const ZigbeeRouterNode = (props: N) => export const ZigbeeEndDeviceNode = (props: N) => + +// Electrical node types +export const GridNode = (props: N) => +export const UpsNode = (props: N) => +export const BatteryNode = (props: N) => +export const GeneratorNode = (props: N) => +export const SolarPanelNode = (props: N) => +export const InverterNode = (props: N) => +export const CircuitBreakerNode = (props: N) => +export const ContactorNode = (props: N) => +export const ElectricalSwitchNode = (props: N) => +export const SocketNode = (props: N) => +export const LightNode = (props: N) => +export const MeterNode = (props: N) => +export const TransformerNode = (props: N) => +export const LoadNode = (props: N) => diff --git a/frontend/src/components/canvas/nodes/nodeTypes.ts b/frontend/src/components/canvas/nodes/nodeTypes.ts index 38e427f..e278307 100644 --- a/frontend/src/components/canvas/nodes/nodeTypes.ts +++ b/frontend/src/components/canvas/nodes/nodeTypes.ts @@ -1,4 +1,12 @@ -import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index' +import { + IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, + NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, + MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, + ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode, + GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode, + CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode, + LightNode, MeterNode, TransformerNode, LoadNode, +} from './index' import { ProxmoxGroupNode } from './ProxmoxGroupNode' import { GroupRectNode } from './GroupRectNode' import { GroupNode } from './GroupNode' @@ -31,4 +39,18 @@ export const nodeTypes = { zigbee_coordinator: ZigbeeCoordinatorNode, zigbee_router: ZigbeeRouterNode, zigbee_enddevice: ZigbeeEndDeviceNode, + grid: GridNode, + ups: UpsNode, + battery: BatteryNode, + generator: GeneratorNode, + solar_panel: SolarPanelNode, + inverter: InverterNode, + circuit_breaker: CircuitBreakerNode, + contactor: ContactorNode, + electrical_switch: ElectricalSwitchNode, + socket: SocketNode, + light: LightNode, + meter: MeterNode, + transformer: TransformerNode, + load: LoadNode, } diff --git a/frontend/src/components/modals/CustomStyleModal.tsx b/frontend/src/components/modals/CustomStyleModal.tsx index a68ff89..b1f6918 100644 --- a/frontend/src/components/modals/CustomStyleModal.tsx +++ b/frontend/src/components/modals/CustomStyleModal.tsx @@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [ 'generic', ] -const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre'] +const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical'] const NODE_ICONS: Record = { isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers, diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx new file mode 100644 index 0000000..d488470 --- /dev/null +++ b/frontend/src/components/modals/DesignModal.tsx @@ -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 ( + !o && onClose()}> + + + {title} + + +
+
+ + setName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }} + placeholder="e.g. Home Network, Rack Power" + autoFocus + /> +
+ +
+ +
+ {DESIGN_ICONS.map((entry) => { + const Icon = entry.icon + const selected = entry.key === icon + return ( + + ) + })} +
+
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/components/modals/NodeModal.tsx b/frontend/src/components/modals/NodeModal.tsx index 6e96714..529f076 100644 --- a/frontend/src/components/modals/NodeModal.tsx +++ b/frontend/src/components/modals/NodeModal.tsx @@ -19,6 +19,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [ { label: 'IoT', types: ['iot', 'camera', 'cpl'] }, { label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] }, { label: 'Personal', types: ['computer', 'laptop', 'mobile'] }, + { label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] }, { label: 'Generic', types: ['generic', 'groupRect'] }, ] diff --git a/frontend/src/components/modals/PendingDevicesModal.tsx b/frontend/src/components/modals/PendingDevicesModal.tsx index 1f770ed..de35096 100644 --- a/frontend/src/components/modals/PendingDevicesModal.tsx +++ b/frontend/src/components/modals/PendingDevicesModal.tsx @@ -3,7 +3,7 @@ import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network, Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, } 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 { useCanvasStore } from '@/stores/canvasStore' import { toast } from 'sonner' @@ -373,12 +373,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus 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() === '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) 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 - }, [open, selectMode, selectedIds, filtered]) + }, [open, selectMode, selectedIds, filtered, statusFilter]) return ( <> @@ -408,9 +416,19 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus )} - +
diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx new file mode 100644 index 0000000..681c668 --- /dev/null +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -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[0]> = {}) { + const onClose = vi.fn() + const onSubmit = vi.fn() + render() + 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() + }) +}) diff --git a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx index 4cbcf97..6470420 100644 --- a/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx +++ b/frontend/src/components/modals/__tests__/PendingDevicesModal.test.tsx @@ -99,6 +99,14 @@ describe('PendingDevicesModal', () => { 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() + 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 () => { render() await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()) @@ -242,4 +250,25 @@ describe('PendingDevicesModal', () => { fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ })) await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a'])) }) + + it('Enter confirms approve in pending select mode', async () => { + render() + 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() + 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() + }) }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index e7554d8..effa9cc 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,10 +1,14 @@ import { useState, useCallback, useEffect, useRef } from 'react' -import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react' +import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' +import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' -import { scanApi, settingsApi } from '@/api/client' +import { designsApi, scanApi, settingsApi } from '@/api/client' +import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' +import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' +import type { Design } from '@/types' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' import { @@ -50,6 +54,37 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee const [activeView, setActiveView] = useState(forceView ?? 'canvas') const [prevForceView, setPrevForceView] = useState(forceView) const logout = useAuthStore((s) => s.logout) + const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() + const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) + const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) + + const handleDesignSubmit = useCallback(async (data: DesignFormData) => { + if (!designModal) return + try { + if (designModal.mode === 'create') { + const res = await designsApi.create({ name: data.name, icon: data.icon }) + addDesign(res.data) + } else if (designModal.design) { + const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon }) + updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon }) + } + setDesignModal(null) + } catch { + toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') + } + }, [designModal, addDesign, updateDesign]) + + const handleDesignDelete = useCallback(async (d: Design) => { + if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } + if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return + try { + await designsApi.delete(d.id) + removeDesign(d.id) + toast.success('Canvas deleted') + } catch { + toast.error('Failed to delete canvas') + } + }, [designs.length, removeDesign]) // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. if (forceView !== prevForceView) { @@ -88,6 +123,75 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee + {/* Design Switcher */} + {!collapsed && designs.length > 0 && ( +
+ + {designSwitcherOpen && ( + <> + {/* Overlay to close */} +
setDesignSwitcherOpen(false)} /> +
+ {designs.map((d) => { + const Icon = resolveDesignIcon(d.icon) + const isActive = d.id === activeDesignId + return ( +
+ + + +
+ ) + })} +
+ +
+ + )} +
+ )} + {/* Views */}