diff --git a/backend/app/api/routes/canvas.py b/backend/app/api/routes/canvas.py index cc7b342..303a804 100644 --- a/backend/app/api/routes/canvas.py +++ b/backend/app/api/routes/canvas.py @@ -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} diff --git a/backend/app/api/routes/designs.py b/backend/app/api/routes/designs.py new file mode 100644 index 0000000..07edad6 --- /dev/null +++ b/backend/app/api/routes/designs.py @@ -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() diff --git a/backend/app/db/database.py b/backend/app/db/database.py index e0eca20..4a96874 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,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), diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 203c36f..e74b7fa 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -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) 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..92b67ab --- /dev/null +++ b/backend/app/schemas/designs.py @@ -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} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 073b7b9..ab5295b 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 { designs, activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore() useStatusPolling() @@ -71,8 +73,9 @@ export default function App() { const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) // Declare handleSave before the Ctrl+S effect so it is in scope - const handleSave = useCallback(async () => { + const handleSave = useCallback(async (designIdOverride?: string) => { try { + const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) markSaved() @@ -81,18 +84,59 @@ export default function App() { } 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') } catch { toast.error('Save failed') } - }, [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, demoNodes, demoEdges]) + + 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, demoNodes, demoEdges]) + // Load canvas on auth (or immediately in standalone mode) useEffect(() => { if (STANDALONE) { @@ -112,28 +156,32 @@ 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) + useEffect(() => { + if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) { + const oldId = prevDesignRef.current + if (oldId && oldId !== activeDesignId) { + // 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. + handleSave(oldId).then(() => { + loadCanvasFromApi(activeDesignId) + }) + } 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/client.ts b/frontend/src/api/client.ts index 1c4485a..ab63b1f 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; design_type: string }) => + api.post('/designs', data), + update: (id: string, data: { name?: 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/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/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/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index e7554d8..b86eca0 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,10 +1,11 @@ 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, Zap, PlusCircle } 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 { canvasApi, designsApi, scanApi, settingsApi } from '@/api/client' import { toast } from 'sonner' import { useLatestRelease } from '@/hooks/useLatestRelease' import { @@ -50,6 +51,9 @@ 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 } = useDesignStore() + const [creating, setCreating] = useState(false) + const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) // forceView acts as a one-shot trigger from parent; user clicks afterwards still control view. if (forceView !== prevForceView) { @@ -88,6 +92,66 @@ 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 = d.design_type === 'electrical' ? Zap : LayoutDashboard + return ( + + ) + })} +
+ +
+ + )} +
+ )} + {/* Views */}