feat: multi-design canvas system with electrical nodes/edges
Backend: New Design model + designs table; design_id FK on nodes, edges, canvas_state; migration seeds default 'Network Topology' design; full CRUD API for designs; canvas load/save accept design_id. Frontend: designStore (Zustand), design switcher in Sidebar, design-aware canvas load/save, auto-save on design switch. Electrical node types (14): grid, ups, battery, generator, solar_panel, inverter, circuit_breaker, contactor, electrical_switch, socket, light, meter, transformer, load — icons, registrations, accent colors in all 6 themes. Electrical edge type: registered in edgeTypes, BASE_STYLES, edgeColors, all theme edgeColors, EDGE_DEFAULT_COLORS. Bug fixes: data corruption on design switch (stale closure), race condition on save-then-load, missing Zap import, missing Electrical group in NodeModal, missing electrical entries in custom theme edgeColors, inline imports hoisted.
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Edge, Node
|
from app.db.models import CanvasState, Design, Edge, Node
|
||||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
@@ -16,10 +16,20 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
async def load_canvas(
|
||||||
nodes = (await db.execute(select(Node))).scalars().all()
|
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
db: AsyncSession = Depends(get_db),
|
||||||
state = await db.get(CanvasState, 1)
|
_: str = Depends(get_current_user),
|
||||||
|
) -> CanvasStateResponse:
|
||||||
|
if design_id is None:
|
||||||
|
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
design_id = first.id if first else None
|
||||||
|
if design_id is None:
|
||||||
|
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||||
|
|
||||||
|
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||||
|
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||||
|
state = await db.get(CanvasState, design_id)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
@@ -33,17 +43,24 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
|||||||
async def save_canvas(
|
async def save_canvas(
|
||||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
|
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_node_ids = {n.id for n in body.nodes}
|
||||||
incoming_edge_ids = {e.id for e in body.edges}
|
incoming_edge_ids = {e.id for e in body.edges}
|
||||||
|
|
||||||
# Delete nodes removed from canvas
|
# Delete nodes removed from canvas (only within this design)
|
||||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||||
for node in existing_nodes:
|
for node in existing_nodes:
|
||||||
if node.id not in incoming_node_ids:
|
if node.id not in incoming_node_ids:
|
||||||
await db.delete(node)
|
await db.delete(node)
|
||||||
|
|
||||||
# Delete edges removed from canvas
|
# Delete edges removed from canvas (only within this design)
|
||||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||||
for edge in existing_edges:
|
for edge in existing_edges:
|
||||||
if edge.id not in incoming_edge_ids:
|
if edge.id not in incoming_edge_ids:
|
||||||
await db.delete(edge)
|
await db.delete(edge)
|
||||||
@@ -53,29 +70,33 @@ async def save_canvas(
|
|||||||
# Upsert nodes
|
# Upsert nodes
|
||||||
for node_data in body.nodes:
|
for node_data in body.nodes:
|
||||||
db_node = await db.get(Node, node_data.id)
|
db_node = await db.get(Node, node_data.id)
|
||||||
|
payload = node_data.model_dump()
|
||||||
|
payload["design_id"] = design_id
|
||||||
if db_node:
|
if db_node:
|
||||||
for field, value in node_data.model_dump().items():
|
for field, value in payload.items():
|
||||||
setattr(db_node, field, value)
|
setattr(db_node, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Node(**node_data.model_dump()))
|
db.add(Node(**payload))
|
||||||
|
|
||||||
# Upsert edges
|
# Upsert edges
|
||||||
for edge_data in body.edges:
|
for edge_data in body.edges:
|
||||||
db_edge = await db.get(Edge, edge_data.id)
|
db_edge = await db.get(Edge, edge_data.id)
|
||||||
|
payload = edge_data.model_dump()
|
||||||
|
payload["design_id"] = design_id
|
||||||
if db_edge:
|
if db_edge:
|
||||||
for field, value in edge_data.model_dump().items():
|
for field, value in payload.items():
|
||||||
setattr(db_edge, field, value)
|
setattr(db_edge, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Edge(**edge_data.model_dump()))
|
db.add(Edge(**payload))
|
||||||
|
|
||||||
# Upsert viewport + custom style
|
# Upsert viewport + custom style
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, design_id)
|
||||||
if state:
|
if state:
|
||||||
state.viewport = body.viewport
|
state.viewport = body.viewport
|
||||||
state.custom_style = body.custom_style
|
state.custom_style = body.custom_style
|
||||||
state.saved_at = datetime.now(timezone.utc)
|
state.saved_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
|
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import uuid as _uuid_mod
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -168,12 +170,91 @@ async def init_db() -> None:
|
|||||||
except OperationalError as exc:
|
except OperationalError as exc:
|
||||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||||
# --- end Zigbee schema migrations -------------------------------------
|
# --- end Zigbee schema migrations -------------------------------------
|
||||||
|
# --- Electrical designs schema migrations -----------------------------
|
||||||
|
# Create designs table (idempotent)
|
||||||
|
await _try_migrate(
|
||||||
|
conn,
|
||||||
|
"CREATE TABLE IF NOT EXISTS designs ("
|
||||||
|
"id VARCHAR PRIMARY KEY,"
|
||||||
|
"name VARCHAR NOT NULL,"
|
||||||
|
"design_type VARCHAR NOT NULL DEFAULT 'network',"
|
||||||
|
"created_at DATETIME,"
|
||||||
|
"updated_at DATETIME"
|
||||||
|
")",
|
||||||
|
label="designs.table",
|
||||||
|
)
|
||||||
|
# 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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
|
|
||||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
rows = await conn.exec_driver_sql(
|
rows = await conn.exec_driver_sql(
|
||||||
@@ -192,7 +273,6 @@ async def init_db() -> None:
|
|||||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
if disk_gb is not None:
|
if disk_gb is not None:
|
||||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
import json as _json
|
|
||||||
await conn.exec_driver_sql(
|
await conn.exec_driver_sql(
|
||||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
(_json.dumps(props), node_id),
|
(_json.dumps(props), node_id),
|
||||||
|
|||||||
@@ -16,12 +16,23 @@ def _uuid() -> str:
|
|||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class Design(Base):
|
||||||
|
__tablename__ = "designs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
|
|
||||||
class Node(Base):
|
class Node(Base):
|
||||||
__tablename__ = "nodes"
|
__tablename__ = "nodes"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
ip: Mapped[str | None] = mapped_column(String)
|
ip: Mapped[str | None] = mapped_column(String)
|
||||||
mac: Mapped[str | None] = mapped_column(String)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -61,6 +72,7 @@ class Edge(Base):
|
|||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
|
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||||
label: Mapped[str | None] = mapped_column(String)
|
label: Mapped[str | None] = mapped_column(String)
|
||||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -77,7 +89,7 @@ class Edge(Base):
|
|||||||
class CanvasState(Base):
|
class CanvasState(Base):
|
||||||
__tablename__ = "canvas_state"
|
__tablename__ = "canvas_state"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, stats, status, zigbee
|
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
|
||||||
from app.api.routes import settings as settings_routes
|
from app.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
@@ -51,6 +51,7 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|||||||
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
|
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class CanvasSaveRequest(BaseModel):
|
|||||||
edges: list[EdgeSave] = []
|
edges: list[EdgeSave] = []
|
||||||
viewport: dict[str, Any] = {}
|
viewport: dict[str, Any] = {}
|
||||||
custom_style: dict[str, Any] | None = None
|
custom_style: dict[str, Any] | None = None
|
||||||
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanvasStateResponse(BaseModel):
|
class CanvasStateResponse(BaseModel):
|
||||||
|
|||||||
@@ -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}
|
||||||
+74
-26
@@ -28,9 +28,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
|
|||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi, designsApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
@@ -44,6 +45,7 @@ export default function App() {
|
|||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
const { designs, activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
@@ -71,8 +73,9 @@ export default function App() {
|
|||||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async (designIdOverride?: string) => {
|
||||||
try {
|
try {
|
||||||
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||||
markSaved()
|
markSaved()
|
||||||
@@ -81,18 +84,59 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
const edgesToSave = edges.map(serializeEdge)
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
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
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||||
|
|
||||||
|
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||||
|
try {
|
||||||
|
const res = await canvasApi.load(designId)
|
||||||
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
|
if (apiNodes.length > 0) {
|
||||||
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
|
(apiNodes as ApiNode[])
|
||||||
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
|
.map((n) => [n.id, true])
|
||||||
|
)
|
||||||
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||||
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
|
loadCanvas(rfNodes, rfEdges)
|
||||||
|
} else {
|
||||||
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
}
|
||||||
|
}, [loadCanvas, setTheme, setCustomStyle, 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)
|
// Load canvas on auth (or immediately in standalone mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
@@ -112,28 +156,32 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) return
|
||||||
canvasApi.load()
|
loadDesignsAndCanvas()
|
||||||
.then((res) => {
|
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
|
||||||
if (apiNodes.length > 0) {
|
// Reload canvas when active design changes (after initial load)
|
||||||
// Build a map of container mode nodes to know if children should be nested
|
const initialLoadDone = useRef(false)
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
const prevDesignRef = useRef<string | null>(null)
|
||||||
(apiNodes as ApiNode[])
|
useEffect(() => {
|
||||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
||||||
.map((n) => [n.id, true])
|
const oldId = prevDesignRef.current
|
||||||
)
|
if (oldId && oldId !== activeDesignId) {
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
// Save current (old) canvas data under the old design ID before switching.
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
// We call handleSave directly (not via ref) so it runs in this effect's
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
// closure where activeDesignId is already the NEW value — the override
|
||||||
if (savedTheme) setTheme(savedTheme)
|
// ensures data is stored under the correct design_id.
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
handleSave(oldId).then(() => {
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvasFromApi(activeDesignId)
|
||||||
} else {
|
})
|
||||||
loadCanvas(demoNodes, demoEdges)
|
} else {
|
||||||
}
|
loadCanvasFromApi(activeDesignId)
|
||||||
})
|
}
|
||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
}
|
||||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle])
|
if (activeDesignId) {
|
||||||
|
prevDesignRef.current = activeDesignId
|
||||||
|
initialLoadDone.current = true
|
||||||
|
}
|
||||||
|
}, [activeDesignId])
|
||||||
|
|
||||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
|
|||||||
@@ -28,12 +28,16 @@ export const authApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const canvasApi = {
|
export const canvasApi = {
|
||||||
load: () => api.get('/canvas'),
|
load: (design_id?: string) => {
|
||||||
|
const params = design_id ? { design_id } : {}
|
||||||
|
return api.get('/canvas', { params })
|
||||||
|
},
|
||||||
save: (payload: {
|
save: (payload: {
|
||||||
nodes: object[]
|
nodes: object[]
|
||||||
edges: object[]
|
edges: object[]
|
||||||
viewport: object
|
viewport: object
|
||||||
custom_style?: object | null
|
custom_style?: object | null
|
||||||
|
design_id?: string | null
|
||||||
}) => api.post('/canvas/save', payload),
|
}) => api.post('/canvas/save', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +93,15 @@ export const settingsApi = {
|
|||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const designsApi = {
|
||||||
|
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||||
|
create: (data: { name: string; design_type: string }) =>
|
||||||
|
api.post<import('@/types').Design>('/designs', data),
|
||||||
|
update: (id: string, data: { name?: string }) =>
|
||||||
|
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
export const zigbeeApi = {
|
export const zigbeeApi = {
|
||||||
testConnection: (data: {
|
testConnection: (data: {
|
||||||
mqtt_host: string
|
mqtt_host: string
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const edgeTypes = {
|
|||||||
virtual: HomelableEdge,
|
virtual: HomelableEdge,
|
||||||
cluster: HomelableEdge,
|
cluster: HomelableEdge,
|
||||||
fibre: HomelableEdge,
|
fibre: HomelableEdge,
|
||||||
|
electrical: HomelableEdge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
||||||
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
||||||
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
|
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
|
||||||
|
electrical: { stroke: edgeColors.electrical, strokeWidth: 2 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const customColor = data?.custom_color as string | undefined
|
const customColor = data?.custom_color as string | undefined
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type NodeProps, type Node } from '@xyflow/react'
|
|||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||||
|
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -32,3 +33,19 @@ export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
|||||||
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||||
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
export const ZigbeeEndDeviceNode = (props: N) => <BaseNode {...props} icon={Antenna} />
|
||||||
|
|
||||||
|
// Electrical node types
|
||||||
|
export const GridNode = (props: N) => <BaseNode {...props} icon={Grid3x3} />
|
||||||
|
export const UpsNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||||
|
export const BatteryNode = (props: N) => <BaseNode {...props} icon={Battery} />
|
||||||
|
export const GeneratorNode = (props: N) => <BaseNode {...props} icon={Fuel} />
|
||||||
|
export const SolarPanelNode = (props: N) => <BaseNode {...props} icon={Sun} />
|
||||||
|
export const InverterNode = (props: N) => <BaseNode {...props} icon={Repeat2} />
|
||||||
|
export const CircuitBreakerNode = (props: N) => <BaseNode {...props} icon={Split} />
|
||||||
|
export const ContactorNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
|
||||||
|
export const ElectricalSwitchNode = (props: N) => <BaseNode {...props} icon={ToggleLeft} />
|
||||||
|
export const SocketNode = (props: N) => <BaseNode {...props} icon={Cable} />
|
||||||
|
export const LightNode = (props: N) => <BaseNode {...props} icon={Lightbulb} />
|
||||||
|
export const MeterNode = (props: N) => <BaseNode {...props} icon={Gauge} />
|
||||||
|
export const TransformerNode = (props: N) => <BaseNode {...props} icon={Combine} />
|
||||||
|
export const LoadNode = (props: N) => <BaseNode {...props} icon={Zap} />
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import { IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode, MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode, ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode } from './index'
|
import {
|
||||||
|
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
|
||||||
|
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||||
|
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||||
|
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||||
|
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
||||||
|
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
||||||
|
LightNode, MeterNode, TransformerNode, LoadNode,
|
||||||
|
} from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
@@ -31,4 +39,18 @@ export const nodeTypes = {
|
|||||||
zigbee_coordinator: ZigbeeCoordinatorNode,
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
zigbee_router: ZigbeeRouterNode,
|
zigbee_router: ZigbeeRouterNode,
|
||||||
zigbee_enddevice: ZigbeeEndDeviceNode,
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
grid: GridNode,
|
||||||
|
ups: UpsNode,
|
||||||
|
battery: BatteryNode,
|
||||||
|
generator: GeneratorNode,
|
||||||
|
solar_panel: SolarPanelNode,
|
||||||
|
inverter: InverterNode,
|
||||||
|
circuit_breaker: CircuitBreakerNode,
|
||||||
|
contactor: ContactorNode,
|
||||||
|
electrical_switch: ElectricalSwitchNode,
|
||||||
|
socket: SocketNode,
|
||||||
|
light: LightNode,
|
||||||
|
meter: MeterNode,
|
||||||
|
transformer: TransformerNode,
|
||||||
|
load: LoadNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
|||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||||
|
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
||||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, Zap, PlusCircle } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { canvasApi, designsApi, scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
import {
|
import {
|
||||||
@@ -50,6 +51,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const { designs, activeDesignId, setActiveDesign } = 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.
|
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||||
if (forceView !== prevForceView) {
|
if (forceView !== prevForceView) {
|
||||||
@@ -88,6 +92,66 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
<Logo size={28} showText={!collapsed} />
|
<Logo size={28} showText={!collapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Design Switcher */}
|
||||||
|
{!collapsed && designs.length > 0 && (
|
||||||
|
<div className="px-2 pt-2 pb-1 border-b border-border relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setDesignSwitcherOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{activeDesignId ? (() => {
|
||||||
|
const active = designs.find((d) => d.id === activeDesignId)
|
||||||
|
const Icon = active?.design_type === 'electrical' ? Zap : LayoutDashboard
|
||||||
|
return <><Icon size={14} className="shrink-0 text-[#00d4ff]" /><span className="truncate text-foreground">{active?.name ?? 'Select Design'}</span></>
|
||||||
|
})() : <span className="text-muted-foreground">Select Design</span>}
|
||||||
|
</button>
|
||||||
|
{designSwitcherOpen && (
|
||||||
|
<>
|
||||||
|
{/* Overlay to close */}
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setDesignSwitcherOpen(false)} />
|
||||||
|
<div className="absolute left-2 right-2 top-full mt-1 z-50 bg-[#21262d] border border-border rounded-md shadow-xl overflow-hidden">
|
||||||
|
{designs.map((d) => {
|
||||||
|
const Icon = d.design_type === 'electrical' ? Zap : LayoutDashboard
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
onClick={() => { setActiveDesign(d.id); setDesignSwitcherOpen(false) }}
|
||||||
|
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors cursor-pointer ${
|
||||||
|
d.id === activeDesignId
|
||||||
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={14} className="shrink-0" />
|
||||||
|
<span className="truncate">{d.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
<button
|
||||||
|
disabled={creating}
|
||||||
|
onClick={async () => {
|
||||||
|
setCreating(true)
|
||||||
|
try {
|
||||||
|
const name = `Electrical ${designs.filter((d) => d.design_type === 'electrical').length + 1}`
|
||||||
|
const res = await designsApi.create({ name, design_type: 'electrical' })
|
||||||
|
useDesignStore.getState().setDesigns([...useDesignStore.getState().designs, res.data])
|
||||||
|
setActiveDesign(res.data.id)
|
||||||
|
setDesignSwitcherOpen(false)
|
||||||
|
} catch { toast.error('Failed to create design') }
|
||||||
|
finally { setCreating(false) }
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<PlusCircle size={14} />
|
||||||
|
<span>New Electrical Design</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Views */}
|
{/* Views */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<nav className="flex flex-col gap-0.5 p-2">
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Design, DesignType } from '@/types'
|
||||||
|
|
||||||
|
interface DesignState {
|
||||||
|
designs: Design[]
|
||||||
|
activeDesignId: string | null
|
||||||
|
activeDesignType: DesignType | null
|
||||||
|
loaded: boolean
|
||||||
|
setDesigns: (designs: Design[]) => void
|
||||||
|
setActiveDesign: (id: string) => void
|
||||||
|
getActiveDesign: () => Design | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDesignStore = create<DesignState>((set, get) => ({
|
||||||
|
designs: [],
|
||||||
|
activeDesignId: null,
|
||||||
|
activeDesignType: null,
|
||||||
|
loaded: false,
|
||||||
|
|
||||||
|
setDesigns: (designs) =>
|
||||||
|
set((state) => {
|
||||||
|
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
|
||||||
|
? state.activeDesignId
|
||||||
|
: designs[0]?.id ?? null
|
||||||
|
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
|
||||||
|
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
|
||||||
|
}),
|
||||||
|
|
||||||
|
setActiveDesign: (id) =>
|
||||||
|
set((state) => {
|
||||||
|
const design = state.designs.find((d) => d.id === id)
|
||||||
|
return {
|
||||||
|
activeDesignId: id,
|
||||||
|
activeDesignType: design?.design_type ?? null,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
getActiveDesign: () => {
|
||||||
|
const { designs, activeDesignId } = get()
|
||||||
|
return designs.find((d) => d.id === activeDesignId) ?? null
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -1,3 +1,13 @@
|
|||||||
|
export type DesignType = 'network' | 'electrical'
|
||||||
|
|
||||||
|
export interface Design {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
design_type: DesignType
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export type NodeType =
|
export type NodeType =
|
||||||
| 'isp'
|
| 'isp'
|
||||||
| 'router'
|
| 'router'
|
||||||
@@ -25,6 +35,20 @@ export type NodeType =
|
|||||||
| 'zigbee_coordinator'
|
| 'zigbee_coordinator'
|
||||||
| 'zigbee_router'
|
| 'zigbee_router'
|
||||||
| 'zigbee_enddevice'
|
| 'zigbee_enddevice'
|
||||||
|
| 'grid'
|
||||||
|
| 'ups'
|
||||||
|
| 'battery'
|
||||||
|
| 'generator'
|
||||||
|
| 'solar_panel'
|
||||||
|
| 'inverter'
|
||||||
|
| 'circuit_breaker'
|
||||||
|
| 'contactor'
|
||||||
|
| 'electrical_switch'
|
||||||
|
| 'socket'
|
||||||
|
| 'light'
|
||||||
|
| 'meter'
|
||||||
|
| 'transformer'
|
||||||
|
| 'load'
|
||||||
|
|
||||||
export type TextPosition =
|
export type TextPosition =
|
||||||
| 'top-left'
|
| 'top-left'
|
||||||
@@ -37,7 +61,7 @@ export type TextPosition =
|
|||||||
| 'bottom-center'
|
| 'bottom-center'
|
||||||
| 'bottom-right'
|
| 'bottom-right'
|
||||||
|
|
||||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre'
|
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' | 'electrical'
|
||||||
|
|
||||||
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
||||||
|
|
||||||
@@ -159,6 +183,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
|||||||
zigbee_coordinator: 'Zigbee Coordinator',
|
zigbee_coordinator: 'Zigbee Coordinator',
|
||||||
zigbee_router: 'Zigbee Router',
|
zigbee_router: 'Zigbee Router',
|
||||||
zigbee_enddevice: 'Zigbee End Device',
|
zigbee_enddevice: 'Zigbee End Device',
|
||||||
|
grid: 'Grid Connection',
|
||||||
|
ups: 'UPS',
|
||||||
|
battery: 'Battery',
|
||||||
|
generator: 'Generator',
|
||||||
|
solar_panel: 'Solar Panel',
|
||||||
|
inverter: 'Inverter',
|
||||||
|
circuit_breaker: 'Circuit Breaker',
|
||||||
|
contactor: 'Contactor',
|
||||||
|
electrical_switch: 'Switch',
|
||||||
|
socket: 'Socket / Outlet',
|
||||||
|
light: 'Light Fixture',
|
||||||
|
meter: 'Energy Meter',
|
||||||
|
transformer: 'Transformer',
|
||||||
|
load: 'Electrical Load',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
export const STATUS_COLORS: Record<NodeStatus, string> = {
|
||||||
@@ -176,6 +214,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
|||||||
virtual: 'Virtual',
|
virtual: 'Virtual',
|
||||||
cluster: 'Cluster',
|
cluster: 'Cluster',
|
||||||
fibre: 'Fibre',
|
fibre: 'Fibre',
|
||||||
|
electrical: 'Electrical Wire',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeTypeStyle {
|
export interface NodeTypeStyle {
|
||||||
|
|||||||
@@ -8,4 +8,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
|||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
fibre: '#22d3ee',
|
fibre: '#22d3ee',
|
||||||
|
electrical: '#e3b341',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
text: { border: '#30363d', icon: '#e6edf3' },
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
|
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
ups: { border: '#39d353', icon: '#39d353' },
|
||||||
|
battery: { border: '#39d353', icon: '#39d353' },
|
||||||
|
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||||
|
contactor: { border: '#f85149', icon: '#f85149' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
light: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
load: { border: '#ec4899', icon: '#ec4899' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
@@ -87,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
fibre: '#22d3ee',
|
fibre: '#22d3ee',
|
||||||
|
electrical:'#e3b341',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00d4ff',
|
edgeSelectedColor: '#00d4ff',
|
||||||
edgeLabelBackground:'#161b22',
|
edgeLabelBackground:'#161b22',
|
||||||
@@ -132,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||||
text: { border: '#404040', icon: '#ffffff' },
|
text: { border: '#404040', icon: '#ffffff' },
|
||||||
|
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
ups: { border: '#39d353', icon: '#39d353' },
|
||||||
|
battery: { border: '#39d353', icon: '#39d353' },
|
||||||
|
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||||
|
contactor: { border: '#f85149', icon: '#f85149' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
light: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
load: { border: '#ec4899', icon: '#ec4899' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0a0a0a',
|
nodeCardBackground: '#0a0a0a',
|
||||||
nodeIconBackground: '#111111',
|
nodeIconBackground: '#111111',
|
||||||
@@ -151,15 +180,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#6b7280',
|
virtual: '#6b7280',
|
||||||
cluster: '#fb923c',
|
cluster: '#fb923c',
|
||||||
fibre: '#06b6d4',
|
fibre: '#06b6d4',
|
||||||
|
electrical:'#e3b341',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#22d3ee',
|
edgeSelectedColor: '#22d3ee',
|
||||||
edgeLabelBackground:'#111111',
|
edgeLabelBackground:'#161b22',
|
||||||
edgeLabelColor: '#666666',
|
edgeLabelColor: '#9ca3af',
|
||||||
edgeLabelBorder: '#1c1c1e',
|
edgeLabelBorder: '#374151',
|
||||||
canvasBackground: '#000000',
|
canvasBackground: '#030712',
|
||||||
canvasDotColor: '#1a1a1a',
|
canvasDotColor: '#374151',
|
||||||
handleBackground: '#1c1c1e',
|
handleBackground: '#374151',
|
||||||
handleBorder: '#444444',
|
handleBorder: '#9ca3af',
|
||||||
reactFlowColorMode: 'dark',
|
reactFlowColorMode: 'dark',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -196,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||||
group: { border: '#0284c7', icon: '#0284c7' },
|
group: { border: '#0284c7', icon: '#0284c7' },
|
||||||
text: { border: '#cbd5e1', icon: '#1f2328' },
|
text: { border: '#cbd5e1', icon: '#1f2328' },
|
||||||
|
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
ups: { border: '#39d353', icon: '#39d353' },
|
||||||
|
battery: { border: '#39d353', icon: '#39d353' },
|
||||||
|
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||||
|
contactor: { border: '#f85149', icon: '#f85149' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
light: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
load: { border: '#ec4899', icon: '#ec4899' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#ffffff',
|
nodeCardBackground: '#ffffff',
|
||||||
nodeIconBackground: '#f0f6ff',
|
nodeIconBackground: '#f0f6ff',
|
||||||
@@ -215,15 +259,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#9ca3af',
|
virtual: '#9ca3af',
|
||||||
cluster: '#ea580c',
|
cluster: '#ea580c',
|
||||||
fibre: '#0891b2',
|
fibre: '#0891b2',
|
||||||
|
electrical:'#d97706',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#0284c7',
|
edgeSelectedColor: '#0284c7',
|
||||||
edgeLabelBackground:'#ffffff',
|
edgeLabelBackground:'#ffffff',
|
||||||
edgeLabelColor: '#57606a',
|
edgeLabelColor: '#6b7280',
|
||||||
edgeLabelBorder: '#d0d7de',
|
edgeLabelBorder: '#d0d7de',
|
||||||
canvasBackground: '#f6f8fa',
|
canvasBackground: '#f6f8fa',
|
||||||
canvasDotColor: '#d0d7de',
|
canvasDotColor: '#d0d7de',
|
||||||
handleBackground: '#d0d7de',
|
handleBackground: '#d0d7de',
|
||||||
handleBorder: '#9ca3af',
|
handleBorder: '#6b7280',
|
||||||
reactFlowColorMode: 'light',
|
reactFlowColorMode: 'light',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -260,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||||
group: { border: '#00ffff', icon: '#00ffff' },
|
group: { border: '#00ffff', icon: '#00ffff' },
|
||||||
text: { border: '#3a3a6a', icon: '#ffffff' },
|
text: { border: '#3a3a6a', icon: '#ffffff' },
|
||||||
|
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
ups: { border: '#39d353', icon: '#39d353' },
|
||||||
|
battery: { border: '#39d353', icon: '#39d353' },
|
||||||
|
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||||
|
contactor: { border: '#f85149', icon: '#f85149' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
light: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
load: { border: '#ec4899', icon: '#ec4899' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#0f0f2a',
|
nodeCardBackground: '#0f0f2a',
|
||||||
nodeIconBackground: '#0a0a1a',
|
nodeIconBackground: '#0a0a1a',
|
||||||
@@ -279,6 +338,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#8888cc',
|
virtual: '#8888cc',
|
||||||
cluster: '#ff8800',
|
cluster: '#ff8800',
|
||||||
fibre: '#00e5ff',
|
fibre: '#00e5ff',
|
||||||
|
electrical:'#ffff00',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00ffff',
|
edgeSelectedColor: '#00ffff',
|
||||||
edgeLabelBackground:'#0a0a1a',
|
edgeLabelBackground:'#0a0a1a',
|
||||||
@@ -324,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||||
group: { border: '#00ff41', icon: '#00ff41' },
|
group: { border: '#00ff41', icon: '#00ff41' },
|
||||||
text: { border: '#003311', icon: '#00ff41' },
|
text: { border: '#003311', icon: '#00ff41' },
|
||||||
|
grid: { border: '#cc6600', icon: '#cc6600' },
|
||||||
|
ups: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
battery: { border: '#00ff41', icon: '#00ff41' },
|
||||||
|
generator: { border: '#ffcc00', icon: '#ffcc00' },
|
||||||
|
solar_panel: { border: '#ffcc00', icon: '#ffcc00' },
|
||||||
|
inverter: { border: '#aa00ff', icon: '#aa00ff' },
|
||||||
|
circuit_breaker: { border: '#ff0033', icon: '#ff0033' },
|
||||||
|
contactor: { border: '#ff0033', icon: '#ff0033' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#005500', icon: '#005500' },
|
||||||
|
light: { border: '#ffcc00', icon: '#ffcc00' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#aa00ff', icon: '#aa00ff' },
|
||||||
|
load: { border: '#ff69b4', icon: '#ff69b4' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#001100',
|
nodeCardBackground: '#001100',
|
||||||
nodeIconBackground: '#002200',
|
nodeIconBackground: '#002200',
|
||||||
@@ -343,6 +417,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#004400',
|
virtual: '#004400',
|
||||||
cluster: '#33ff66',
|
cluster: '#33ff66',
|
||||||
fibre: '#00ffcc',
|
fibre: '#00ffcc',
|
||||||
|
electrical:'#66ff33',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00ff41',
|
edgeSelectedColor: '#00ff41',
|
||||||
edgeLabelBackground:'#001100',
|
edgeLabelBackground:'#001100',
|
||||||
@@ -388,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
text: { border: '#30363d', icon: '#e6edf3' },
|
text: { border: '#30363d', icon: '#e6edf3' },
|
||||||
|
grid: { border: '#ff6e00', icon: '#ff6e00' },
|
||||||
|
ups: { border: '#39d353', icon: '#39d353' },
|
||||||
|
battery: { border: '#39d353', icon: '#39d353' },
|
||||||
|
generator: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
solar_panel: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
inverter: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
circuit_breaker: { border: '#f85149', icon: '#f85149' },
|
||||||
|
contactor: { border: '#f85149', icon: '#f85149' },
|
||||||
|
electrical_switch: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
socket: { border: '#8b949e', icon: '#8b949e' },
|
||||||
|
light: { border: '#e3b341', icon: '#e3b341' },
|
||||||
|
meter: { border: '#00d4ff', icon: '#00d4ff' },
|
||||||
|
transformer: { border: '#a855f7', icon: '#a855f7' },
|
||||||
|
load: { border: '#ec4899', icon: '#ec4899' },
|
||||||
},
|
},
|
||||||
nodeCardBackground: '#21262d',
|
nodeCardBackground: '#21262d',
|
||||||
nodeIconBackground: '#161b22',
|
nodeIconBackground: '#161b22',
|
||||||
@@ -407,6 +496,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
|||||||
virtual: '#8b949e',
|
virtual: '#8b949e',
|
||||||
cluster: '#ff6e00',
|
cluster: '#ff6e00',
|
||||||
fibre: '#22d3ee',
|
fibre: '#22d3ee',
|
||||||
|
electrical: '#e3b341',
|
||||||
},
|
},
|
||||||
edgeSelectedColor: '#00d4ff',
|
edgeSelectedColor: '#00d4ff',
|
||||||
edgeLabelBackground:'#161b22',
|
edgeLabelBackground:'#161b22',
|
||||||
|
|||||||
Reference in New Issue
Block a user