Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1efc1c95ca | |||
| b70ee78d25 | |||
| 9efaaccb51 | |||
| 6e5bda5860 | |||
| 0796c96fc1 | |||
| 6fba0cdec4 | |||
| 2a4d109ee6 | |||
| 96bb048228 | |||
| 88c006764a | |||
| 9c3dace12f | |||
| ec53363fdb | |||
| 0e260ede73 | |||
| 60902167c0 | |||
| 592e7865c5 | |||
| f082c295fd | |||
| 10b981ad1d | |||
| 2058e453ff | |||
| b776b6b8f1 | |||
| fe3787f0cd | |||
| 7ea1fc2d69 | |||
| 71cc183efb | |||
| 7873f8b94f | |||
| b34b40794d | |||
| ed310f0d8d | |||
| 2db173cd56 | |||
| d032103b7d | |||
| bcc488993d | |||
| aa0a97c245 | |||
| a39d4d4406 | |||
| 7c2417f5a9 | |||
| ea66e6c9c7 | |||
| f78bac6e6b | |||
| a0bfb97517 | |||
| ff9c4735e0 | |||
| 2f3e986d4a | |||
| 0a4bac30ac | |||
| ba2f6564ec | |||
| 754a79d21f | |||
| b52bbc6d9f | |||
| c67b1775a5 | |||
| 5940e11860 | |||
| fa8f949b45 | |||
| 6e17a2afc6 | |||
| 2375efa6b7 | |||
| 61fbebb13e | |||
| 05fb67e7f0 | |||
| 1a426f6301 | |||
| f56dfea838 | |||
| 6784b8f6be | |||
| 01fce64b2c | |||
| 79093fdcfb | |||
| 4ce8a12720 | |||
| cc2cc72482 | |||
| 12d527aad6 | |||
| 73b479a408 | |||
| 16183285b5 | |||
| eb7b0c6d38 | |||
| be320c2ac4 | |||
| 60ce648a96 | |||
| d5e35a885d | |||
| 3a57d809a4 | |||
| 46435605eb |
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke-and-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Weekly on Monday
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,6 +46,7 @@ htmlcov/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.back
|
||||
*.db.back-*
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -16,10 +17,20 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
async def load_canvas(
|
||||
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> CanvasStateResponse:
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
@@ -32,18 +43,28 @@ async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
@router.post("/save")
|
||||
async def save_canvas(
|
||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
) -> dict[str, bool | str]:
|
||||
design_id = body.design_id
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
|
||||
db.add(new_design)
|
||||
await db.flush()
|
||||
design_id = new_design.id
|
||||
|
||||
incoming_node_ids = {n.id for n in body.nodes}
|
||||
incoming_edge_ids = {e.id for e in body.edges}
|
||||
|
||||
# Delete nodes removed from canvas
|
||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
||||
# Delete nodes removed from canvas (only within this design)
|
||||
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for node in existing_nodes:
|
||||
if node.id not in incoming_node_ids:
|
||||
await db.delete(node)
|
||||
|
||||
# Delete edges removed from canvas
|
||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
||||
# Delete edges removed from canvas (only within this design)
|
||||
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for edge in existing_edges:
|
||||
if edge.id not in incoming_edge_ids:
|
||||
await db.delete(edge)
|
||||
@@ -53,29 +74,33 @@ async def save_canvas(
|
||||
# Upsert nodes
|
||||
for node_data in body.nodes:
|
||||
db_node = await db.get(Node, node_data.id)
|
||||
payload = node_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_node:
|
||||
for field, value in node_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**node_data.model_dump()))
|
||||
db.add(Node(**payload))
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
db_edge = await db.get(Edge, edge_data.id)
|
||||
payload = edge_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_edge:
|
||||
for field, value in edge_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_edge, field, value)
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
db.add(Edge(**payload))
|
||||
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, 1)
|
||||
state = await db.get(CanvasState, design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport, custom_style=body.custom_style))
|
||||
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DesignResponse])
|
||||
async def list_designs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[DesignResponse]:
|
||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
||||
return [DesignResponse.model_validate(d) for d in designs]
|
||||
|
||||
|
||||
@router.post("", response_model=DesignResponse, status_code=201)
|
||||
async def create_design(
|
||||
body: DesignCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
# Create empty canvas state for the new design
|
||||
db.add(CanvasState(design_id=design.id))
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.put("/{design_id}", response_model=DesignResponse)
|
||||
async def update_design(
|
||||
design_id: str,
|
||||
body: DesignUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
if body.name is not None:
|
||||
design.name = body.name
|
||||
if body.icon is not None:
|
||||
design.icon = body.icon
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.delete("/{design_id}", status_code=204)
|
||||
async def delete_design(
|
||||
design_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> None:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
# Count remaining designs — prevent deleting the last one
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, edges, nodes
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for n in nodes:
|
||||
await db.delete(n)
|
||||
await db.delete(design)
|
||||
await db.commit()
|
||||
@@ -2,12 +2,14 @@ import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -15,9 +17,30 @@ from app.schemas.nodes import NodeResponse
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LiveViewConfigResponse(BaseModel):
|
||||
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
|
||||
|
||||
enabled: bool
|
||||
key: str | None = None
|
||||
|
||||
|
||||
@router.get("/config", response_model=LiveViewConfigResponse)
|
||||
async def liveview_config(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> LiveViewConfigResponse:
|
||||
"""Authenticated: expose the configured live view key so the UI can build a
|
||||
ready-to-use share link (e.g. /view?key=...&design=<id>).
|
||||
|
||||
Only reachable by a logged-in user — the key is never exposed publicly.
|
||||
"""
|
||||
key = settings.liveview_key or None
|
||||
return LiveViewConfigResponse(enabled=bool(key), key=key)
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def liveview_canvas(
|
||||
key: str | None = Query(default=None),
|
||||
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CanvasStateResponse:
|
||||
"""Read-only public canvas endpoint.
|
||||
@@ -30,9 +53,15 @@ async def liveview_canvas(
|
||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
return CanvasStateResponse(
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
@@ -146,6 +146,10 @@ async def bulk_approve_devices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design (use first design as fallback)
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
default_design_id = first_design.id if first_design else None
|
||||
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
@@ -173,6 +177,7 @@ async def bulk_approve_devices(
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||
design_id=default_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
created_nodes.append(node)
|
||||
@@ -256,6 +261,12 @@ async def approve_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design
|
||||
node_design_id = node_data.design_id
|
||||
if node_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
@@ -280,6 +291,7 @@ async def approve_device(
|
||||
) if _is_zigbee else merge_mac_property(node_data.properties, _mac),
|
||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if _is_zigbee else node_data.check_target,
|
||||
design_id=node_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
@@ -361,12 +373,18 @@ async def _resolve_pending_links_for_ieee(
|
||||
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
||||
await db.delete(link)
|
||||
continue
|
||||
# Use the source node's design_id for the edge
|
||||
edge_design_id = self_node.design_id if self_node else None
|
||||
if edge_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
edge_design_id = first.id if first else None
|
||||
edge = Edge(
|
||||
source=src_id,
|
||||
target=tgt_id,
|
||||
type="iot",
|
||||
source_handle="bottom",
|
||||
target_handle="top-t",
|
||||
design_id=edge_design_id,
|
||||
)
|
||||
db.add(edge)
|
||||
await db.flush()
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"""App-level settings (status checker interval, etc.)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
interval_seconds: int
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = Field(default=300, ge=30)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
||||
return AppSettings(
|
||||
interval_seconds=settings.status_checker_interval,
|
||||
service_check_enabled=settings.service_check_enabled,
|
||||
service_check_interval=settings.service_check_interval,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AppSettings)
|
||||
@@ -23,7 +30,13 @@ async def update_settings(
|
||||
) -> AppSettings:
|
||||
try:
|
||||
settings.status_checker_interval = payload.interval_seconds
|
||||
settings.service_check_enabled = payload.service_check_enabled
|
||||
settings.service_check_interval = payload.service_check_interval
|
||||
settings.save_overrides()
|
||||
# Apply the service-check schedule live.
|
||||
set_service_checks_enabled(payload.service_check_enabled)
|
||||
if payload.service_check_enabled:
|
||||
reschedule_service_checks(payload.service_check_interval)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import contextlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
@@ -10,6 +11,12 @@ router = APIRouter()
|
||||
_connections: list[WebSocket] = []
|
||||
|
||||
|
||||
def _drop(websocket: WebSocket) -> None:
|
||||
"""Remove a connection if still present — idempotent, never raises."""
|
||||
with contextlib.suppress(ValueError):
|
||||
_connections.remove(websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/status")
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
_connections.remove(websocket)
|
||||
pass
|
||||
finally:
|
||||
# Any error (disconnect or otherwise) must release the slot, else the
|
||||
# dead socket lingers in the broadcast pool.
|
||||
_drop(websocket)
|
||||
|
||||
|
||||
async def _broadcast(payload: str) -> None:
|
||||
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
|
||||
try:
|
||||
await conn.send_text(payload)
|
||||
except Exception:
|
||||
_connections.remove(conn)
|
||||
_drop(conn)
|
||||
|
||||
|
||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "service_status",
|
||||
"node_id": node_id,
|
||||
"services": services,
|
||||
"checked_at": checked_at,
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "scan_device_found",
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeCoordinatorOut,
|
||||
@@ -138,6 +138,10 @@ async def _persist_pending_import(
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
||||
All zigbee-source links are wiped and re-inserted from the new map.
|
||||
"""
|
||||
# Determine target design (use first design as fallback)
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
default_design_id = first_design.id if first_design else None
|
||||
|
||||
coordinator_out: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_existed = False
|
||||
pending_created = 0
|
||||
@@ -174,6 +178,7 @@ async def _persist_pending_import(
|
||||
ieee_address=ieee,
|
||||
services=[],
|
||||
properties=props,
|
||||
design_id=default_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
|
||||
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
# Per-service status checker (independent of node checks). Off by default.
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = 300
|
||||
|
||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||
# Used by the MCP server to authenticate against the backend without a user password.
|
||||
# Leave empty to disable MCP service key auth.
|
||||
@@ -77,6 +81,10 @@ class Settings(BaseSettings):
|
||||
self.scanner_ranges = data["scanner_ranges"]
|
||||
if "status_checker_interval" in data:
|
||||
self.status_checker_interval = int(data["status_checker_interval"])
|
||||
if "service_check_enabled" in data:
|
||||
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||
if "service_check_interval" in data:
|
||||
self.service_check_interval = int(data["service_check_interval"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -86,6 +94,8 @@ class Settings(BaseSettings):
|
||||
self._override_path().write_text(json.dumps({
|
||||
"scanner_ranges": self.scanner_ranges,
|
||||
"status_checker_interval": self.status_checker_interval,
|
||||
"service_check_enabled": self.service_check_enabled,
|
||||
"service_check_interval": self.service_check_interval,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal
|
||||
from app.db.models import Node
|
||||
from app.services.status_checker import check_node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
||||
])
|
||||
|
||||
|
||||
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||
"""Pick the address to probe services on: first IP, else hostname."""
|
||||
if ip:
|
||||
first = ip.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return hostname or None
|
||||
|
||||
|
||||
async def _run_service_checks() -> None:
|
||||
"""Check every service of every node and broadcast per-service results."""
|
||||
if not settings.service_check_enabled:
|
||||
return
|
||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
checkable = [
|
||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||
for n in nodes
|
||||
if n.services
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||
except Exception as exc:
|
||||
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||
|
||||
|
||||
def _add_service_check_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_service_checks,
|
||||
"interval",
|
||||
seconds=settings.service_check_interval,
|
||||
id="service_checks",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
if settings.service_check_enabled:
|
||||
_add_service_check_job()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 30:
|
||||
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("service_checks"):
|
||||
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_service_checks_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the service-check job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("service_checks")
|
||||
if enabled and not job:
|
||||
_add_service_check_job()
|
||||
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("service_checks")
|
||||
logger.info("Service checks disabled")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
+101
-5
@@ -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
|
||||
@@ -89,6 +91,8 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||
with suppress(OperationalError):
|
||||
@@ -168,20 +172,113 @@ async def init_db() -> None:
|
||||
except OperationalError as exc:
|
||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
await _try_migrate(
|
||||
conn,
|
||||
"CREATE TABLE IF NOT EXISTS designs ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"name VARCHAR NOT NULL,"
|
||||
"design_type VARCHAR NOT NULL DEFAULT 'network',"
|
||||
"created_at DATETIME,"
|
||||
"updated_at DATETIME"
|
||||
")",
|
||||
label="designs.table",
|
||||
)
|
||||
# Add user-chosen icon to designs (idempotent), then backfill existing rows
|
||||
# so legacy designs keep a sensible icon based on their original type.
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
|
||||
)
|
||||
# Seed default Network Topology design if designs table is empty
|
||||
_default_design_id = str(_uuid_mod.uuid4())
|
||||
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
|
||||
count_row = row.fetchone()
|
||||
count = count_row[0] if count_row else 0
|
||||
if count == 0:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
|
||||
(_default_design_id,),
|
||||
)
|
||||
else:
|
||||
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
|
||||
default = row2.fetchone()
|
||||
_default_design_id = default[0] if default else _default_design_id
|
||||
|
||||
# Add design_id to nodes
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="nodes.design_id",
|
||||
)
|
||||
# Assign existing nodes to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Add design_id to edges
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="edges.design_id",
|
||||
)
|
||||
# Assign existing edges to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
|
||||
try:
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
|
||||
cols = info.fetchall()
|
||||
has_design_id = any(c[1] == "design_id" for c in cols)
|
||||
if not has_design_id:
|
||||
logger.info("Migrating canvas_state: switching to design_id primary key")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE canvas_state_new ("
|
||||
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
|
||||
"viewport JSON,"
|
||||
"custom_style JSON,"
|
||||
"saved_at DATETIME"
|
||||
")"
|
||||
)
|
||||
# Copy existing row(s), mapping id=1 to default design_id
|
||||
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
|
||||
for old in old_rows.fetchall():
|
||||
cs_id, viewport, custom_style, saved_at = old
|
||||
target_design = _default_design_id
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(target_design, viewport, custom_style, saved_at),
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE canvas_state")
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state ADD COLUMN custom_style JSON")
|
||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
for r in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
@@ -192,7 +289,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),
|
||||
|
||||
@@ -16,12 +16,24 @@ def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Design(Base):
|
||||
__tablename__ = "designs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class Node(Base):
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
ip: Mapped[str | None] = mapped_column(String)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
@@ -42,6 +54,7 @@ class Node(Base):
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
@@ -61,6 +74,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 +91,7 @@ class Edge(Base):
|
||||
class CanvasState(Base):
|
||||
__tablename__ = "canvas_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+2
-1
@@ -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"])
|
||||
|
||||
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
show_port_numbers: bool = False
|
||||
properties: list[Any] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
@@ -63,6 +64,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):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DesignCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = "dashboard"
|
||||
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
|
||||
# the chosen icon now drives presentation. Defaults to a generic canvas.
|
||||
design_type: str = "network"
|
||||
|
||||
|
||||
class DesignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
class DesignResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
design_type: str
|
||||
icon: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -26,7 +26,7 @@ class EdgeBase(BaseModel):
|
||||
|
||||
|
||||
class EdgeCreate(EdgeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class EdgeUpdate(BaseModel):
|
||||
|
||||
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
show_port_numbers: bool = False
|
||||
properties: list[dict[str, Any]] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
@@ -34,7 +35,7 @@ class NodeBase(BaseModel):
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
show_port_numbers: bool | None = None
|
||||
properties: list[dict[str, Any]] | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
@@ -68,6 +70,8 @@ class NodeUpdate(BaseModel):
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
ieee_address: str | None = None
|
||||
last_seen: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
return {"status": "offline", "response_time_ms": None}
|
||||
|
||||
|
||||
def _is_ipv6(host: str) -> bool:
|
||||
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
# ping(8) -W flag units differ by OS:
|
||||
# Linux: seconds (-W 1 = 1s)
|
||||
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
||||
# Windows: -w in ms (-w 1000 = 1s)
|
||||
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||
#
|
||||
# -W flag units differ by OS:
|
||||
# Linux: seconds (-W 2 = 2s)
|
||||
# macOS: milliseconds (-W 2000 = 2s)
|
||||
# Windows: -w in ms (-w 2000 = 2s)
|
||||
#
|
||||
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||
ipv6 = _is_ipv6(host)
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
family = ["-6"] if ipv6 else ["-4"]
|
||||
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||
elif sys.platform == "darwin":
|
||||
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
family = ["-6"] if ipv6 else []
|
||||
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
@@ -100,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
||||
return True
|
||||
except (TimeoutError, OSError, socket.gaierror):
|
||||
return False
|
||||
|
||||
|
||||
# --- Per-service status checks ---
|
||||
|
||||
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||
_NON_HTTP_PORTS = frozenset({
|
||||
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||
})
|
||||
_HTTPS_PORTS = frozenset({443, 8443})
|
||||
|
||||
|
||||
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||
"""Bracket bare IPv6 literals for use in a URL."""
|
||||
return f"[{host}]" if _is_ipv6(host) else host
|
||||
|
||||
|
||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||
|
||||
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||
"""
|
||||
if not host or host.startswith("-"):
|
||||
return "unknown"
|
||||
if str(svc.get("protocol", "")).lower() == "udp":
|
||||
return "unknown"
|
||||
|
||||
port = svc.get("port")
|
||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||
|
||||
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||
if port is not None and port in _NON_HTTP_PORTS:
|
||||
return "unknown"
|
||||
|
||||
name = str(svc.get("service_name", "")).lower()
|
||||
is_web = port is not None or "http" in name
|
||||
if not is_web:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
scheme = "https" if (
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http"
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
return "online" if await _http_get(url, verify=False) else "offline"
|
||||
except Exception as exc:
|
||||
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||
return "offline"
|
||||
|
||||
|
||||
async def check_services(
|
||||
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Check every service against host concurrently (bounded).
|
||||
|
||||
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||
"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
status = await check_service(svc, host)
|
||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||
|
||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||
|
||||
@@ -8,14 +8,14 @@ pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.27
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
pyyaml==6.0.2
|
||||
types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.149.7
|
||||
zeroconf==0.149.12
|
||||
aiomqtt==2.3.0
|
||||
|
||||
# Dev
|
||||
|
||||
@@ -199,6 +199,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
||||
assert canvas["nodes"][0]["show_hardware"] is False
|
||||
|
||||
|
||||
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
||||
# toggle reset on every reload.
|
||||
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(show_port_numbers=True)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is True
|
||||
|
||||
|
||||
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is False
|
||||
|
||||
|
||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
|
||||
|
||||
def node_payload(**kwargs):
|
||||
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
||||
|
||||
|
||||
def edge_payload(src, tgt, **kwargs):
|
||||
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
|
||||
|
||||
|
||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/designs")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_create_design_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/designs", json={"name": "X"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ── list / create ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_empty(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/designs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_create_design_defaults(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Workshop")
|
||||
assert design["name"] == "Workshop"
|
||||
assert design["design_type"] == "network"
|
||||
assert design["icon"] == "dashboard"
|
||||
assert "id" in design and design["id"]
|
||||
|
||||
|
||||
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Net", design_type="network")
|
||||
assert design["design_type"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Power", icon="zap")
|
||||
assert design["icon"] == "zap"
|
||||
|
||||
|
||||
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="D", icon="dashboard")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["icon"] == "server"
|
||||
# Name left untouched when only icon is sent.
|
||||
assert res.json()["name"] == "D"
|
||||
|
||||
|
||||
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Old", icon="dashboard")
|
||||
res = await client.put(
|
||||
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["name"] == "New"
|
||||
assert body["icon"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Has Canvas")
|
||||
# Loading the new design returns an (empty) canvas without falling back to another design.
|
||||
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == []
|
||||
assert body["edges"] == []
|
||||
|
||||
|
||||
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
|
||||
a = await _create(client, headers, name="First")
|
||||
b = await _create(client, headers, name="Second")
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
ids = [d["id"] for d in listed]
|
||||
assert ids == [a["id"], b["id"]]
|
||||
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Old Name")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "New Name"
|
||||
|
||||
|
||||
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Only One")
|
||||
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
|
||||
await _create(client, headers, name="Keep")
|
||||
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
|
||||
keep = await _create(client, headers, name="Keep")
|
||||
victim = await _create(client, headers, name="Victim")
|
||||
|
||||
# Populate the victim design with nodes + an edge via canvas save.
|
||||
n1 = node_payload(label="A")
|
||||
n2 = node_payload(label="B")
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
|
||||
# Populate the kept design too, to prove scoping.
|
||||
k1 = node_payload(label="K")
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
# Victim gone from list.
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [keep["id"]]
|
||||
|
||||
# Kept design's node survives untouched.
|
||||
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
|
||||
assert len(kept_canvas["nodes"]) == 1
|
||||
assert kept_canvas["nodes"][0]["label"] == "K"
|
||||
@@ -146,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Live view is disabled"
|
||||
|
||||
|
||||
# ── /config (authenticated) — key used to build share links ──────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_requires_auth(client: AsyncClient):
|
||||
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
|
||||
settings.liveview_key = "secret"
|
||||
res = await client.get("/api/v1/liveview/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = "share-me"
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {"enabled": True, "key": "share-me"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = None
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False, "key": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = ""
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False, "key": None}
|
||||
|
||||
|
||||
# ── design_id selects which canvas is rendered ───────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
|
||||
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
|
||||
# Create two designs
|
||||
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
||||
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
|
||||
|
||||
# Save a distinct node into each design
|
||||
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
|
||||
payload = {
|
||||
"nodes": [{
|
||||
"id": node_id,
|
||||
"type": "server",
|
||||
"label": label,
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
}],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
"design_id": design["id"],
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
# Requesting d2 returns only the electrical node
|
||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
|
||||
assert res.status_code == 200
|
||||
nodes = res.json()["nodes"]
|
||||
assert [n["id"] for n in nodes] == ["n-elec"]
|
||||
|
||||
# Requesting d1 returns only the network node
|
||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
|
||||
assert res.status_code == 200
|
||||
nodes = res.json()["nodes"]
|
||||
assert [n["id"] for n in nodes] == ["n-net"]
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Backward-compatibility tests for the legacy → multi-design migration.
|
||||
|
||||
Simulates a database created by a pre-"designs" version of the app and asserts
|
||||
that running init_db() adopts all existing nodes/edges/canvas into a single
|
||||
default "Network Topology" design with no data loss. The rest of the test suite
|
||||
builds the *current* schema via create_all and never exercises this upgrade
|
||||
path, so this file guards real users upgrading in place.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def legacy_engine(tmp_path, monkeypatch):
|
||||
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
|
||||
db_path = tmp_path / "legacy.db"
|
||||
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
return db_path, engine
|
||||
|
||||
|
||||
async def _build_legacy_schema(engine) -> None:
|
||||
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
|
||||
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
|
||||
"custom_style JSON, saved_at DATETIME)"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||
"VALUES ('n1','server','Old Server','online','[]',10,20)"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
|
||||
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
|
||||
)
|
||||
|
||||
|
||||
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
||||
db_path, engine = legacy_engine
|
||||
await _build_legacy_schema(engine)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
# Exactly one seeded default design.
|
||||
designs = (await conn.exec_driver_sql(
|
||||
"SELECT id, name, design_type, icon FROM designs"
|
||||
)).fetchall()
|
||||
assert len(designs) == 1
|
||||
did, name, dtype, icon = designs[0]
|
||||
assert name == "Network Topology"
|
||||
assert dtype == "network"
|
||||
assert icon == "dashboard"
|
||||
|
||||
# Every legacy node adopted into the default design, data preserved.
|
||||
nodes = (await conn.exec_driver_sql(
|
||||
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
|
||||
)).fetchall()
|
||||
assert [(n[0], n[1], n[2]) for n in nodes] == [
|
||||
("n1", "Old Server", "online"),
|
||||
("n2", "Old Router", "offline"),
|
||||
]
|
||||
assert all(n[3] == did for n in nodes)
|
||||
|
||||
# Legacy edge adopted too.
|
||||
edge = (await conn.exec_driver_sql(
|
||||
"SELECT design_id FROM edges WHERE id='e1'"
|
||||
)).fetchone()
|
||||
assert edge[0] == did
|
||||
|
||||
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
|
||||
# default design and the viewport survives.
|
||||
cs = (await conn.exec_driver_sql(
|
||||
"SELECT design_id, viewport FROM canvas_state"
|
||||
)).fetchall()
|
||||
assert len(cs) == 1
|
||||
assert cs[0][0] == did
|
||||
assert "zoom" in (cs[0][1] or "")
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_migration_is_idempotent(legacy_engine):
|
||||
"""Running init_db twice must not duplicate the design or drop any data."""
|
||||
db_path, engine = legacy_engine
|
||||
await _build_legacy_schema(engine)
|
||||
|
||||
await database.init_db()
|
||||
await database.init_db() # second boot — should be a no-op
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
|
||||
assert len(designs) == 1
|
||||
did = designs[0][0]
|
||||
|
||||
nodes = (await conn.exec_driver_sql(
|
||||
"SELECT design_id FROM nodes"
|
||||
)).fetchall()
|
||||
assert len(nodes) == 2
|
||||
assert all(n[0] == did for n in nodes)
|
||||
|
||||
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
|
||||
assert len(cs) == 1
|
||||
assert cs[0][0] == did
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
||||
from app.core.scheduler import (
|
||||
_run_service_checks,
|
||||
_run_status_checks,
|
||||
set_service_checks_enabled,
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
)
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node
|
||||
|
||||
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
mock_settings.status_checker_interval = 45
|
||||
mock_settings.service_check_enabled = False
|
||||
start_scheduler()
|
||||
_, kwargs = mock_sched.add_job.call_args
|
||||
assert kwargs["seconds"] == 45
|
||||
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.start.assert_called_once()
|
||||
mock_sched.shutdown.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
||||
mock_settings.service_check_enabled = False
|
||||
await _run_service_checks()
|
||||
mock_cs.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
async with mem_db() as session:
|
||||
node = _make_node(
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
|
||||
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
||||
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
||||
mock_settings.service_check_enabled = True
|
||||
await _run_service_checks()
|
||||
|
||||
mock_bcast.assert_awaited_once()
|
||||
_, kwargs = mock_bcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
assert kwargs["services"] == statuses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
||||
mock_settings.service_check_enabled = True
|
||||
await _run_service_checks()
|
||||
mock_cs.assert_not_called()
|
||||
|
||||
|
||||
def test_set_service_checks_enabled_adds_and_removes_job():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||
patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.service_check_interval = 300
|
||||
# Enable: no existing job -> add
|
||||
mock_sched.get_job.return_value = None
|
||||
set_service_checks_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
# Disable: existing job -> remove
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_service_checks_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("service_checks")
|
||||
|
||||
|
||||
def test_start_scheduler_adds_service_job_when_enabled():
|
||||
mock_sched = MagicMock()
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
mock_settings.status_checker_interval = 60
|
||||
mock_settings.service_check_enabled = True
|
||||
mock_settings.service_check_interval = 300
|
||||
start_scheduler()
|
||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||
assert "status_checks" in job_ids
|
||||
assert "service_checks" in job_ids
|
||||
|
||||
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/settings", headers=headers)
|
||||
data = res.json()
|
||||
assert "service_check_enabled" in data
|
||||
assert "service_check_interval" in data
|
||||
assert isinstance(data["service_check_enabled"], bool)
|
||||
assert isinstance(data["service_check_interval"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||
mock_settings.save_overrides = lambda: None
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"interval_seconds": 60,
|
||||
"service_check_enabled": True,
|
||||
"service_check_interval": 600,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["service_check_enabled"] is True
|
||||
assert body["service_check_interval"] == 600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
@@ -5,7 +5,13 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
||||
from app.api.routes.status import (
|
||||
_connections,
|
||||
_drop,
|
||||
broadcast_scan_update,
|
||||
broadcast_service_status,
|
||||
broadcast_status,
|
||||
)
|
||||
from app.main import app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
|
||||
assert len(_connections) == 0
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# broadcast_service_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_service_status_payload():
|
||||
received: list[str] = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_text(self, text: str) -> None:
|
||||
received.append(text)
|
||||
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_service_status(
|
||||
node_id="node-7",
|
||||
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||
checked_at="2024-01-01T00:00:00",
|
||||
)
|
||||
finally:
|
||||
_drop(fake)
|
||||
|
||||
msg = json.loads(received[0])
|
||||
assert msg["type"] == "service_status"
|
||||
assert msg["node_id"] == "node-7"
|
||||
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _drop — idempotent connection removal (regression for double-remove crash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_drop_is_idempotent():
|
||||
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
||||
class FakeWS:
|
||||
pass
|
||||
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
_drop(fake)
|
||||
_drop(fake) # second drop must be a no-op
|
||||
assert fake not in _connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dead_connection_dropped_once_safely():
|
||||
"""A send failure removes the dead socket without a double-remove crash."""
|
||||
class DeadWS:
|
||||
async def send_text(self, _: str) -> None:
|
||||
raise RuntimeError("disconnected")
|
||||
|
||||
dead = DeadWS()
|
||||
_connections.append(dead)
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
# A second broadcast must not raise even though dead is already gone.
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
assert dead not in _connections
|
||||
|
||||
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||
from app.services.status_checker import (
|
||||
_ping,
|
||||
_tcp_connect,
|
||||
check_node,
|
||||
check_service,
|
||||
check_services,
|
||||
)
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@@ -169,9 +175,14 @@ async def test_ping_uses_unix_args_on_non_windows():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
assert "-n" not in captured["args"]
|
||||
# Linux: -W is in seconds; 1s is the intended timeout
|
||||
# 2 probes so a single dropped packet doesn't flap the node offline
|
||||
c_idx = captured["args"].index("-c")
|
||||
assert captured["args"][c_idx + 1] == "2"
|
||||
# Linux: -W is in seconds; 2s is the intended timeout
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1"
|
||||
assert captured["args"][w_idx + 1] == "2"
|
||||
# IPv4 target → no -6 flag
|
||||
assert "-6" not in captured["args"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -193,7 +204,7 @@ async def test_ping_uses_macos_millisecond_timeout():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1000"
|
||||
assert captured["args"][w_idx + 1] == "2000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -216,6 +227,75 @@ async def test_ping_uses_windows_args_on_win32():
|
||||
assert "-c" not in captured["args"]
|
||||
|
||||
|
||||
# --- _ping IPv6 support ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_linux_uses_dash6():
|
||||
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("fe80::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
assert captured["args"][-1] == "fe80::1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_macos_uses_ping6():
|
||||
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert captured["args"][0] == "ping6"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_windows_uses_dash6():
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
|
||||
|
||||
def test_is_ipv6_detection():
|
||||
from app.services.status_checker import _is_ipv6
|
||||
|
||||
assert _is_ipv6("fe80::1") is True
|
||||
assert _is_ipv6("2001:db8::1") is True
|
||||
assert _is_ipv6("[2001:db8::1]") is True
|
||||
assert _is_ipv6("192.168.1.1") is False
|
||||
assert _is_ipv6("example.local") is False
|
||||
|
||||
|
||||
# --- check_node target validation ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -268,3 +348,117 @@ async def test_tcp_connect_os_error():
|
||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||
result = await _tcp_connect("192.168.1.1", 9999)
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- check_service ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_no_host_is_unknown():
|
||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_flag_host_is_unknown():
|
||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_udp_is_unknown():
|
||||
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_portless_non_web_is_unknown():
|
||||
svc = {"protocol": "tcp", "service_name": "thing"}
|
||||
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_web_uses_http_get():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "online"
|
||||
assert captured["url"] == "http://10.0.0.1:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_https_port_uses_https_scheme():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, "10.0.0.1")
|
||||
assert captured["url"].startswith("https://")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_web_offline_when_http_fails():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
||||
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_non_http_port_is_unknown():
|
||||
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_tcp.assert_not_called()
|
||||
mock_http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_ssh_port_22_is_unknown():
|
||||
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_tcp.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_ipv6_brackets_url_host():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, "2001:db8::1")
|
||||
assert captured["url"] == "http://[2001:db8::1]:80"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_returns_status_per_service():
|
||||
services = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||
]
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||
results = await check_services("10.0.0.1", services)
|
||||
assert results == [
|
||||
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_empty_list():
|
||||
assert await check_services("10.0.0.1", []) == []
|
||||
|
||||
Generated
+136
-136
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.1",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -52,7 +52,7 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
@@ -964,9 +964,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -981,9 +981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -998,9 +998,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1015,9 +1015,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1032,9 +1032,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1049,9 +1049,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1066,9 +1066,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1083,9 +1083,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1100,9 +1100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1117,9 +1117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1134,9 +1134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1151,9 +1151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1168,9 +1168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -1185,9 +1185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1202,9 +1202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1219,9 +1219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1236,9 +1236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1253,9 +1253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1270,9 +1270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1287,9 +1287,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1304,9 +1304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1321,9 +1321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1338,9 +1338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1355,9 +1355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1372,9 +1372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1389,9 +1389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3237,9 +3237,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -3651,9 +3651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4250,9 +4250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.13",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
||||
"version": "1.1.15",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5216,9 +5216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -5229,32 +5229,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.7",
|
||||
"@esbuild/android-arm": "0.27.7",
|
||||
"@esbuild/android-arm64": "0.27.7",
|
||||
"@esbuild/android-x64": "0.27.7",
|
||||
"@esbuild/darwin-arm64": "0.27.7",
|
||||
"@esbuild/darwin-x64": "0.27.7",
|
||||
"@esbuild/freebsd-arm64": "0.27.7",
|
||||
"@esbuild/freebsd-x64": "0.27.7",
|
||||
"@esbuild/linux-arm": "0.27.7",
|
||||
"@esbuild/linux-arm64": "0.27.7",
|
||||
"@esbuild/linux-ia32": "0.27.7",
|
||||
"@esbuild/linux-loong64": "0.27.7",
|
||||
"@esbuild/linux-mips64el": "0.27.7",
|
||||
"@esbuild/linux-ppc64": "0.27.7",
|
||||
"@esbuild/linux-riscv64": "0.27.7",
|
||||
"@esbuild/linux-s390x": "0.27.7",
|
||||
"@esbuild/linux-x64": "0.27.7",
|
||||
"@esbuild/netbsd-arm64": "0.27.7",
|
||||
"@esbuild/netbsd-x64": "0.27.7",
|
||||
"@esbuild/openbsd-arm64": "0.27.7",
|
||||
"@esbuild/openbsd-x64": "0.27.7",
|
||||
"@esbuild/openharmony-arm64": "0.27.7",
|
||||
"@esbuild/sunos-x64": "0.27.7",
|
||||
"@esbuild/win32-arm64": "0.27.7",
|
||||
"@esbuild/win32-ia32": "0.27.7",
|
||||
"@esbuild/win32-x64": "0.27.7"
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
@@ -5877,16 +5877,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -6161,9 +6161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
@@ -6196,9 +6196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.18",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||
"version": "4.12.25",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -8040,9 +8040,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -9267,9 +9267,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"version": "7.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.5.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -37,6 +37,11 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.21",
|
||||
"esbuild": "^0.28.1",
|
||||
"form-data": "^4.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
@@ -58,7 +63,7 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
+185
-47
@@ -20,17 +20,21 @@ import { LoginPage } from '@/components/LoginPage'
|
||||
import { NodeModal } from '@/components/modals/NodeModal'
|
||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||
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, liveviewApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
@@ -40,16 +44,17 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||
@@ -65,34 +70,83 @@ export default function App() {
|
||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||
const handleSave = useCallback(async () => {
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
||||
try {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
if (STANDALONE) {
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
return true
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle })
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return true
|
||||
} catch {
|
||||
toast.error('Save failed')
|
||||
return false
|
||||
}
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle])
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
} catch {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
if (STANDALONE) return
|
||||
try {
|
||||
const res = await designsApi.list()
|
||||
const loadedDesigns = res.data
|
||||
setDesigns(loadedDesigns)
|
||||
const targetId = activeDesignId ?? loadedDesigns[0]?.id
|
||||
if (targetId) {
|
||||
setActiveDesign(targetId)
|
||||
await loadCanvasFromApi(targetId)
|
||||
}
|
||||
} catch {
|
||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
|
||||
|
||||
// Load canvas on auth (or immediately in standalone mode)
|
||||
useEffect(() => {
|
||||
if (STANDALONE) {
|
||||
@@ -112,38 +166,59 @@ 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<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))
|
||||
}, [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<string | null>(null)
|
||||
// Set while we programmatically revert activeDesignId after a failed save, so
|
||||
// the re-entrant effect run skips save/load and just re-syncs the refs.
|
||||
const revertingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (revertingRef.current) {
|
||||
revertingRef.current = false
|
||||
prevDesignRef.current = activeDesignId
|
||||
return
|
||||
}
|
||||
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
||||
const oldId = prevDesignRef.current
|
||||
// If the previous design was deleted (no longer in the list), don't try to
|
||||
// save into it — just load the newly-selected design.
|
||||
const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false
|
||||
if (oldId && oldId !== activeDesignId && oldStillExists) {
|
||||
// Save current (old) canvas data under the old design ID before switching.
|
||||
// We call handleSave directly (not via ref) so it runs in this effect's
|
||||
// closure where activeDesignId is already the NEW value — the override
|
||||
// ensures data is stored under the correct design_id.
|
||||
const targetId = activeDesignId
|
||||
handleSave(oldId).then((ok) => {
|
||||
if (ok) {
|
||||
loadCanvasFromApi(targetId)
|
||||
} else {
|
||||
// Save failed: don't load the new design — that would overwrite the
|
||||
// unsaved in-memory canvas. Revert the selection back to the old
|
||||
// design so the UI matches the data still on screen.
|
||||
toast.error('Switch cancelled — unsaved changes kept')
|
||||
revertingRef.current = true
|
||||
setActiveDesign(oldId)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadCanvasFromApi(activeDesignId)
|
||||
}
|
||||
}
|
||||
if (activeDesignId) {
|
||||
prevDesignRef.current = activeDesignId
|
||||
initialLoadDone.current = true
|
||||
}
|
||||
}, [activeDesignId])
|
||||
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
const redoRef = useRef(redo)
|
||||
const copyRef = useRef(copySelectedNodes)
|
||||
const pasteRef = useRef(pasteNodes)
|
||||
useEffect(() => { undoRef.current = undo }, [undo])
|
||||
useEffect(() => { redoRef.current = redo }, [redo])
|
||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -157,8 +232,8 @@ export default function App() {
|
||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||
// nodes under the cursor / viewport center.
|
||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
@@ -170,9 +245,16 @@ export default function App() {
|
||||
const id = generateUUID()
|
||||
const isContainerNode = data.container_mode === true
|
||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||
// Children position is relative to parent; place near top-left with padding
|
||||
const position = parentNode
|
||||
? { x: 20, y: 50 }
|
||||
// Only nest when the parent is an actual container. For a non-container
|
||||
// parent the LXC/VM stays a free node (linked by a virtual edge) — setting
|
||||
// extent:'parent' on a non-container would trap it inside the parent's tiny
|
||||
// bounding box with no way to drag it out (issue #205 follow-up).
|
||||
const nestInParent = !!parentNode?.data.container_mode
|
||||
// Seed an ABSOLUTE position near the container's top-left; addNode converts
|
||||
// it to container-relative. addNode is the single authority for parentId /
|
||||
// extent, so we don't set them here.
|
||||
const position = nestInParent && parentNode
|
||||
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
|
||||
: { x: 300, y: 300 }
|
||||
|
||||
const newNode: Node<NodeData> = {
|
||||
@@ -180,7 +262,6 @@ export default function App() {
|
||||
type: data.type ?? 'generic',
|
||||
position,
|
||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||
}
|
||||
addNode(newNode)
|
||||
@@ -384,6 +465,28 @@ export default function App() {
|
||||
}
|
||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||
|
||||
// Open the read-only live view of the currently active design in a new tab.
|
||||
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||
const handleViewOnly = useCallback(async () => {
|
||||
if (STANDALONE) {
|
||||
window.open('/view', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await liveviewApi.getConfig()
|
||||
if (!res.data.enabled || !res.data.key) {
|
||||
toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env')
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams({ key: res.data.key })
|
||||
if (activeDesignId) params.set('design', activeDesignId)
|
||||
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
|
||||
} catch {
|
||||
toast.error('Failed to open live view')
|
||||
}
|
||||
}, [activeDesignId])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||
if (!el) { toast.error('Canvas not ready'); return }
|
||||
@@ -513,7 +616,8 @@ export default function App() {
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
forceView={sidebarForceView}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||
onOpenPending={openPendingModal}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
@@ -528,6 +632,7 @@ export default function App() {
|
||||
onExportMd={handleExportMd}
|
||||
onExportYaml={handleExportYaml}
|
||||
onImportYaml={handleImportYaml}
|
||||
onViewOnly={handleViewOnly}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
@@ -536,6 +641,8 @@ export default function App() {
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onRequestAddToGroup={setPendingGroupAdd}
|
||||
onRequestAddToContainer={setPendingContainerAdd}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
</div>
|
||||
@@ -550,7 +657,7 @@ export default function App() {
|
||||
onClose={() => setAddNodeOpen(false)}
|
||||
onSubmit={handleAddNode}
|
||||
title="Add Node"
|
||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
@@ -577,7 +684,7 @@ export default function App() {
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => !descendants.has(n.id))
|
||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||
})()}
|
||||
currentNodeId={editNodeId ?? undefined}
|
||||
/>
|
||||
@@ -611,8 +718,6 @@ export default function App() {
|
||||
onClose={() => setScanConfigOpen(false)}
|
||||
onScanNow={() => {
|
||||
toast.success('Network scan started — check Scan History for results')
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => setSidebarForceView('history'), 0)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -623,12 +728,18 @@ export default function App() {
|
||||
onClose={() => setZigbeeImportOpen(false)}
|
||||
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => setSidebarForceView('history'), 0)
|
||||
toast.success('Zigbee import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
onClose={() => setScanHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GroupRectModal
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
@@ -709,6 +820,33 @@ export default function App() {
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingGroupAdd}
|
||||
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||
setPendingGroupAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingGroupAdd(null)}
|
||||
/>
|
||||
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingContainerAdd}
|
||||
variant="container"
|
||||
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||
setPendingContainerAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingContainerAdd(null)}
|
||||
/>
|
||||
|
||||
{!STANDALONE && (
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
<PendingDevicesModal
|
||||
open={pendingModalOpen}
|
||||
onClose={() => setPendingModalOpen(false)}
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('api/client', () => {
|
||||
|
||||
it('canvasApi.load GETs /canvas', () => {
|
||||
mod.canvasApi.load()
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas')
|
||||
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||
})
|
||||
|
||||
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
||||
@@ -158,6 +158,16 @@ describe('api/client', () => {
|
||||
expect(api.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('liveviewApi.load forwards design as design_id when provided', () => {
|
||||
mod.liveviewApi.load('k-1', 'design-9')
|
||||
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } })
|
||||
})
|
||||
|
||||
it('liveviewApi.getConfig hits the authenticated config endpoint', () => {
|
||||
mod.liveviewApi.getConfig()
|
||||
expect(api.get).toHaveBeenCalledWith('/liveview/config')
|
||||
})
|
||||
|
||||
it('scanApi endpoints route correctly', () => {
|
||||
mod.scanApi.trigger()
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||
@@ -194,8 +204,8 @@ describe('api/client', () => {
|
||||
it('settingsApi get/save', () => {
|
||||
mod.settingsApi.get()
|
||||
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||
mod.settingsApi.save({ interval_seconds: 30 })
|
||||
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
||||
mod.settingsApi.save({ interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||
})
|
||||
|
||||
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -49,7 +53,9 @@ export const edgesApi = {
|
||||
}
|
||||
|
||||
export const liveviewApi = {
|
||||
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
||||
load: (key: string, design?: string) =>
|
||||
publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }),
|
||||
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
||||
}
|
||||
|
||||
export const scanApi = {
|
||||
@@ -84,9 +90,24 @@ export const scanApi = {
|
||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
interval_seconds: number
|
||||
service_check_enabled: boolean
|
||||
service_check_interval: number
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||
get: () => api.get<AppSettings>('/settings'),
|
||||
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||
}
|
||||
|
||||
export const designsApi = {
|
||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||
api.post<import('@/types').Design>('/designs', data),
|
||||
update: (id: string, data: { name?: string; icon?: string }) =>
|
||||
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||
}
|
||||
|
||||
export const zigbeeApi = {
|
||||
|
||||
@@ -68,10 +68,14 @@ function LiveViewCanvas() {
|
||||
}
|
||||
|
||||
// Already handled synchronously in useState initializer
|
||||
const key = new URLSearchParams(window.location.search).get('key')
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const key = search.get('key')
|
||||
if (!key) return
|
||||
// Optional ?design=<id> selects which canvas to render; backend falls back
|
||||
// to the first design when omitted.
|
||||
const design = search.get('design') ?? undefined
|
||||
|
||||
liveviewApi.load(key)
|
||||
liveviewApi.load(key, design)
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const proxmoxMap = new Map<string, boolean>(
|
||||
@@ -158,6 +162,8 @@ function LiveViewCanvas() {
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
maxZoom={2.5}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodeClick={onNodeClick}
|
||||
|
||||
@@ -5,9 +5,15 @@ import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||
|
||||
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
|
||||
let rfProps: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: () => <div data-testid="react-flow" />,
|
||||
ReactFlow: (props: Record<string, unknown>) => {
|
||||
rfProps = props
|
||||
return <div data-testid="react-flow" />
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
@@ -49,6 +55,7 @@ const canvasPayload = {
|
||||
|
||||
describe('LiveView (non-standalone)', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||
})
|
||||
@@ -111,7 +118,26 @@ describe('LiveView (non-standalone)', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
|
||||
})
|
||||
|
||||
it('forwards ?design=<id> to the API so a specific canvas is loaded', async () => {
|
||||
setSearch('?key=correct-key&design=elec-123')
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123')
|
||||
})
|
||||
|
||||
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
|
||||
setSearch('?key=correct-key')
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
|
||||
// can't zoom out far enough to fit.
|
||||
expect(rfProps.minZoom).toBe(0.25)
|
||||
expect(rfProps.maxZoom).toBe(2.5)
|
||||
})
|
||||
|
||||
it('loads nodes into the canvas store on success', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -30,18 +30,46 @@ interface CanvasContainerProps {
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
copySelectedNodes, pasteNodes,
|
||||
} = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||
|
||||
// Track the last cursor position over the canvas so paste lands under it.
|
||||
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||
}, [])
|
||||
|
||||
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||
// can project the cursor / viewport center into flow coordinates.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return
|
||||
const el = e.target as HTMLElement
|
||||
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
|
||||
if (isInput) return
|
||||
if (e.key === 'c') {
|
||||
copySelectedNodes()
|
||||
} else if (e.key === 'v') {
|
||||
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||
pasteNodes(screenToFlowPosition(screen))
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
|
||||
|
||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||
useEffect(() => {
|
||||
@@ -99,8 +127,26 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||
// before the alignment snap so detection uses the dropped position.
|
||||
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||
if (dragNode && !dragNode.parentId &&
|
||||
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||
const intersecting = getIntersectingNodes(dragNode)
|
||||
const group = intersecting.find((n) => n.data.type === 'group')
|
||||
if (group) {
|
||||
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||
} else {
|
||||
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||
}
|
||||
}
|
||||
onNodeDragStop(event, dragNode, dragNodes)
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||
<ReactFlow
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
@@ -113,7 +159,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
|
||||
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
||||
useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
|
||||
sel({ hideIp: false, serviceStatuses: {} }),
|
||||
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/themes', () => ({
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
// Capture props passed to ReactFlow so we can test the callbacks
|
||||
let rfProps: Record<string, unknown> = {}
|
||||
|
||||
// Hoisted holder so the mock factory can read the configurable intersection set.
|
||||
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlow: (props: Record<string, unknown>) => {
|
||||
rfProps = props
|
||||
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
useReactFlow: () => ({
|
||||
fitView: vi.fn(),
|
||||
screenToFlowPosition: vi.fn(),
|
||||
getIntersectingNodes: () => rf.intersecting,
|
||||
setNodes: vi.fn(),
|
||||
getNodes: () => [],
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
|
||||
describe('CanvasContainer', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
rf.intersecting = []
|
||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
|
||||
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
||||
})
|
||||
|
||||
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
|
||||
|
||||
function groupNode(id: string): Node<NodeData> {
|
||||
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
|
||||
}
|
||||
|
||||
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
|
||||
const onRequestAddToGroup = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
const group = groupNode('g1')
|
||||
rf.intersecting = [group]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToGroup when no group is under the node', () => {
|
||||
const onRequestAddToGroup = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [makeNode('n2')]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToGroup for an already-parented node', () => {
|
||||
const onRequestAddToGroup = vi.fn()
|
||||
const node = { ...makeNode('n1'), parentId: 'gOther' }
|
||||
rf.intersecting = [groupNode('g1')]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
|
||||
const onRequestAddToGroup = vi.fn()
|
||||
const node = groupNode('g2')
|
||||
rf.intersecting = [groupNode('g1')]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Drag onto container node → onRequestAddToContainer ────────────────────
|
||||
|
||||
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
|
||||
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
|
||||
}
|
||||
|
||||
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [containerNode('px1')]
|
||||
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||
})
|
||||
|
||||
it('prefers a group over a container when both intersect', () => {
|
||||
const onRequestAddToGroup = vi.fn()
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToContainer for an already-parented node', () => {
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const node = { ...makeNode('n1'), parentId: 'pxOther' }
|
||||
rf.intersecting = [containerNode('px1')]
|
||||
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const node = makeNode('n1')
|
||||
rf.intersecting = [makeNode('n2')]
|
||||
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||
|
||||
it('enables snapToGrid', () => {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||
import { HomelableEdge } from '../index'
|
||||
import type { EdgeData } from '@/types'
|
||||
|
||||
/**
|
||||
* Regression: edge flow animations must use CSS, never SVG SMIL <animate>.
|
||||
*
|
||||
* SMIL <animate> keeps running while the tab is hidden and leaks memory in
|
||||
* Chrome over time (RAM climbed only when the canvas tab was backgrounded).
|
||||
* CSS animations pause when the tab is hidden and don't leak — so the rendered
|
||||
* output must contain a CSS `animation` on the path and zero <animate> nodes.
|
||||
*/
|
||||
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||
const props = {
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
sourcePosition: 'bottom',
|
||||
targetPosition: 'top',
|
||||
data: { type: 'ethernet', ...data } as EdgeData,
|
||||
selected: false,
|
||||
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<svg>
|
||||
<HomelableEdge {...props} />
|
||||
</svg>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomelableEdge animation', () => {
|
||||
it('renders snake animation as CSS, not SMIL <animate>', () => {
|
||||
const { container } = renderEdge({ animated: 'snake' })
|
||||
expect(container.querySelector('animate')).toBeNull()
|
||||
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||
)
|
||||
expect(animated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders flow animation as CSS, not SMIL <animate>', () => {
|
||||
const { container } = renderEdge({ animated: 'flow' })
|
||||
expect(container.querySelector('animate')).toBeNull()
|
||||
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||
(p.getAttribute('style') ?? '').includes('homelable-flow'),
|
||||
)
|
||||
expect(animated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('legacy animated:true maps to snake CSS animation', () => {
|
||||
const { container } = renderEdge({ animated: true })
|
||||
expect(container.querySelector('animate')).toBeNull()
|
||||
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||
)
|
||||
expect(animated).toBeTruthy()
|
||||
})
|
||||
|
||||
it('non-animated edge has no flow animation and no <animate>', () => {
|
||||
const { container } = renderEdge({ animated: false })
|
||||
expect(container.querySelector('animate')).toBeNull()
|
||||
const animated = Array.from(container.querySelectorAll('path')).find((p) => {
|
||||
const s = p.getAttribute('style') ?? ''
|
||||
return s.includes('homelable-snake') || s.includes('homelable-flow')
|
||||
})
|
||||
expect(animated).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||
import type { EdgeData } from '@/types'
|
||||
|
||||
/**
|
||||
* Issue #183 — connection labels must support multiple lines.
|
||||
*
|
||||
* The label is a free-text string; newlines entered in the EdgeModal textarea
|
||||
* are stored verbatim. The rendered label div must preserve those newlines
|
||||
* (`whitespace-pre-line`) instead of collapsing them into a single line.
|
||||
*
|
||||
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
|
||||
* full <ReactFlow> host, so we stub it to a passthrough to render the label
|
||||
* markup directly.
|
||||
*/
|
||||
vi.mock('@xyflow/react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@xyflow/react')>()
|
||||
return {
|
||||
...actual,
|
||||
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}
|
||||
})
|
||||
|
||||
const { HomelableEdge } = await import('../index')
|
||||
|
||||
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||
const props = {
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
sourcePosition: 'bottom',
|
||||
targetPosition: 'top',
|
||||
data: { type: 'ethernet', ...data } as EdgeData,
|
||||
selected: false,
|
||||
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<svg>
|
||||
<HomelableEdge {...props} />
|
||||
</svg>
|
||||
</ReactFlowProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('HomelableEdge label', () => {
|
||||
it('renders the label text', () => {
|
||||
const { getByText } = renderEdge({ label: 'uplink' })
|
||||
expect(getByText('uplink')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('preserves newlines in the rendered label (issue #183)', () => {
|
||||
const { container } = renderEdge({ label: 'line one\nline two' })
|
||||
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
|
||||
d.textContent === 'line one\nline two',
|
||||
)
|
||||
expect(label).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders no label div when label is empty', () => {
|
||||
const { container } = renderEdge({ label: undefined })
|
||||
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -8,4 +8,5 @@ export const edgeTypes = {
|
||||
virtual: HomelableEdge,
|
||||
cluster: 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' },
|
||||
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
|
||||
@@ -377,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||
strokeDasharray="20 10000"
|
||||
strokeLinecap="round"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{isBidirectional ? (
|
||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
||||
) : (
|
||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
||||
)}
|
||||
</path>
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
// CSS (not SMIL) so it pauses when the tab is hidden — see index.css.
|
||||
// Bidirectional yo-yos via `alternate` (10s each way = 20s round trip,
|
||||
// matching the old SMIL keyTimes); unidirectional loops in one direction.
|
||||
animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{animMode === 'flow' && (
|
||||
<path
|
||||
@@ -395,16 +396,17 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
strokeDasharray="6 12"
|
||||
strokeLinecap="round"
|
||||
strokeOpacity={0.85}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
||||
</path>
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
animation: 'homelable-flow 1.2s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EdgeLabelRenderer>
|
||||
{data?.label && (
|
||||
<div
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
|
||||
style={{
|
||||
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||
background: theme.colors.edgeLabelBackground,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
@@ -31,6 +31,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
const theme = THEMES[activeTheme]
|
||||
|
||||
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||
@@ -151,6 +152,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||
{services.map((svc, idx) => {
|
||||
const url = getServiceUrl(svc, serviceHost)
|
||||
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
||||
const row = (
|
||||
<div
|
||||
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||
@@ -164,7 +166,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
{/* LEFT: service name */}
|
||||
<span
|
||||
className="font-medium truncate"
|
||||
style={{ minWidth: 0 }}
|
||||
style={{ minWidth: 0, color: svcOffline ? '#f85149' : undefined }}
|
||||
title={svc.service_name}
|
||||
>
|
||||
{svc.service_name}
|
||||
|
||||
@@ -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) => <BaseNode {...props} icon={Circle} />
|
||||
export const ZigbeeCoordinatorNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||
export const ZigbeeRouterNode = (props: N) => <BaseNode {...props} icon={Radio} />
|
||||
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 { 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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Layers } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ConfirmAddToGroupModalProps {
|
||||
open: boolean
|
||||
nodeLabel: string
|
||||
/** Label of the destination group/container. */
|
||||
targetLabel: string
|
||||
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||
variant?: 'group' | 'container'
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmAddToGroupModal({
|
||||
open,
|
||||
nodeLabel,
|
||||
targetLabel,
|
||||
variant = 'group',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmAddToGroupModalProps) {
|
||||
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||
const noun = variant === 'container' ? 'container' : 'group'
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Layers size={16} className="text-[#00d4ff]" />
|
||||
{action}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
'generic',
|
||||
]
|
||||
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre', 'electrical']
|
||||
|
||||
const NODE_ICONS: Record<string, LucideIcon> = {
|
||||
isp: Globe, router: Router, firewall: Flame, switch: Network, server: Server, proxmox: Layers,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
export interface DesignFormData {
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface DesignModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: DesignFormData) => void
|
||||
initial?: DesignFormData
|
||||
title?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
onSubmit({ name: trimmed, icon })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="design-name">Name</Label>
|
||||
<Input
|
||||
id="design-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder="e.g. Home Network, Rack Power"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Icon</Label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{DESIGN_ICONS.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
const selected = entry.key === icon
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-label={entry.label}
|
||||
aria-pressed={selected}
|
||||
title={entry.label}
|
||||
onClick={() => setIcon(entry.key)}
|
||||
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>{submitLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
||||
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
e.preventDefault()
|
||||
onSubmit({
|
||||
type,
|
||||
label: label || undefined,
|
||||
label: label.trim() || undefined,
|
||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||
custom_color: customColor,
|
||||
path_style: pathStyle,
|
||||
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
|
||||
<Input
|
||||
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
|
||||
<Textarea
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="e.g. 1G, trunk..."
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||
placeholder={'e.g. 1G, trunk...\nsecond line'}
|
||||
rows={2}
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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'] },
|
||||
]
|
||||
|
||||
@@ -54,6 +55,8 @@ interface ParentCandidate {
|
||||
id: string
|
||||
label: string
|
||||
type: NodeType
|
||||
/** True when the node has container mode on, so any node can nest inside it. */
|
||||
container_mode?: boolean
|
||||
}
|
||||
|
||||
interface NodeModalProps {
|
||||
@@ -97,12 +100,14 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
const validParentTypes = getValidParentTypes(selectedType)
|
||||
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||
// because the candidate is a container-mode node (any child can nest in it).
|
||||
const isValidParent = (p: ParentCandidate) =>
|
||||
validParentTypes.includes(p.type) || p.container_mode === true
|
||||
let safeParentId = form.parent_id
|
||||
if (validParentTypes.length === 0) {
|
||||
safeParentId = undefined
|
||||
} else if (safeParentId) {
|
||||
if (safeParentId) {
|
||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||
}
|
||||
onSubmit({
|
||||
...form,
|
||||
@@ -129,7 +134,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
setForm((f) => {
|
||||
const next: Partial<NodeData> = { ...f, type: t }
|
||||
if (ZIGBEE_TYPES.includes(t)) next.check_method = 'none' as CheckMethod
|
||||
if (getValidParentTypes(t).length === 0) next.parent_id = undefined
|
||||
// Drop the parent only if it's no longer a valid target for the
|
||||
// new type — keep container-mode parents (any node can nest).
|
||||
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||
next.parent_id = undefined
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}>
|
||||
@@ -348,9 +358,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
{(() => {
|
||||
const childType = (form.type ?? 'generic') as NodeType
|
||||
const validParentTypes = getValidParentTypes(childType)
|
||||
if (validParentTypes.length === 0) return null
|
||||
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||
// container-mode node. The current parent is always kept so an
|
||||
// already-nested node can be re-targeted or detached here.
|
||||
const validParents = parentCandidates.filter(
|
||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||
(n) => n.id !== currentNodeId &&
|
||||
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||
)
|
||||
if (validParents.length === 0) return null
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
|
||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2,
|
||||
} from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
@@ -373,12 +373,20 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
if (e.key === '/') { e.preventDefault(); searchRef.current?.focus() }
|
||||
else if (e.key.toLowerCase() === 's') { e.preventDefault(); if (selectMode) exitSelectMode(); else enterSelectMode() }
|
||||
else if (e.key.toLowerCase() === 'a' && selectMode) { e.preventDefault(); selectAllVisible() }
|
||||
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) { e.preventDefault(); handleBulkApprove() }
|
||||
else if (e.key === 'Enter' && selectMode && selectedIds.size > 0) {
|
||||
// Enter confirms the bulk action for the current view: approving
|
||||
// hidden devices would be wrong — they restore.
|
||||
e.preventDefault()
|
||||
if (statusFilter === 'hidden') handleBulkRestore()
|
||||
else handleBulkApprove()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
// statusFilter is included so Enter dispatches the correct bulk action
|
||||
// (approve vs restore) even if the device list doesn't change on switch.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, selectMode, selectedIds, filtered])
|
||||
}, [open, selectMode, selectedIds, filtered, statusFilter])
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -408,9 +416,19 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||
{/* Route the close X through Base UI's DialogClose (same path as
|
||||
outside-click) instead of a raw onClick — the latter's synthetic
|
||||
click was being dropped on Firefox/Windows. */}
|
||||
<DialogClose
|
||||
render={
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||
aria-label="Close"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
|
||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface ScanRun {
|
||||
id: string
|
||||
status: string
|
||||
kind?: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface ScanHistoryModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
type KindFilter = 'all' | 'ip' | 'zigbee'
|
||||
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'running', label: 'Running' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'error', label: 'Error' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
]
|
||||
|
||||
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'ip', label: 'IP' },
|
||||
{ key: 'zigbee', label: 'Zigbee' },
|
||||
]
|
||||
|
||||
function statusColor(s: string): string {
|
||||
return s === 'done' ? '#39d353'
|
||||
: s === 'running' ? '#e3b341'
|
||||
: s === 'error' ? '#f85149'
|
||||
: s === 'cancelled' ? '#8b949e'
|
||||
: '#8b949e'
|
||||
}
|
||||
|
||||
function parseUtc(ts: string): number {
|
||||
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 0) ms = 0
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = s % 60
|
||||
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
return `${h}h ${m % 60}m`
|
||||
}
|
||||
|
||||
function runDuration(r: ScanRun, now: number): string {
|
||||
const start = parseUtc(r.started_at)
|
||||
const end = r.finished_at ? parseUtc(r.finished_at) : now
|
||||
return formatDuration(end - start)
|
||||
}
|
||||
|
||||
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const prevRunsRef = useRef<ScanRun[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.runs()
|
||||
const next: ScanRun[] = res.data
|
||||
|
||||
// Surface transitions and refresh dependent UI
|
||||
for (const run of next) {
|
||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||
if (prev?.status === 'running' && run.status === 'error') {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (prev?.status === 'running' && run.status === 'done') {
|
||||
if (run.kind === 'zigbee') {
|
||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
}
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
}
|
||||
}
|
||||
prevRunsRef.current = next
|
||||
setRuns(next)
|
||||
} catch {
|
||||
toast.error('Failed to load scan history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
prevRunsRef.current = []
|
||||
load()
|
||||
}, [open, load])
|
||||
|
||||
// Auto-refresh every 3s while any run is still running (only when open)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const hasRunning = runs.some((r) => r.status === 'running')
|
||||
if (!hasRunning) return
|
||||
const id = setInterval(load, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [open, runs, load])
|
||||
|
||||
// Tick the clock every second while a scan is running (for live elapsed duration)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const hasRunning = runs.some((r) => r.status === 'running')
|
||||
if (!hasRunning) return
|
||||
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [open, runs])
|
||||
|
||||
const handleStop = async (runId: string) => {
|
||||
setStopping(runId)
|
||||
try {
|
||||
await scanApi.stop(runId)
|
||||
toast.success('Scan stop requested')
|
||||
} catch {
|
||||
toast.error('Failed to stop scan')
|
||||
} finally {
|
||||
setStopping(null)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = runs.filter((r) => {
|
||||
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
|
||||
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||
>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||
<Clock size={16} className="text-[#00d4ff]" />
|
||||
Scan History
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
|
||||
</span>
|
||||
</DialogTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
|
||||
</button>
|
||||
<DialogClose
|
||||
render={
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||
aria-label="Close"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<X size={14} />
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
|
||||
{KIND_FILTERS.map((f) => (
|
||||
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
|
||||
{f.label}
|
||||
</FilterChip>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
|
||||
{f.label}
|
||||
</FilterChip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
|
||||
{loading && runs.length === 0 && (
|
||||
<div className="flex justify-center py-10">
|
||||
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
|
||||
<Inbox size={28} className="opacity-50" />
|
||||
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
|
||||
</div>
|
||||
)}
|
||||
{filtered.map((r) => {
|
||||
const isZigbee = r.kind === 'zigbee'
|
||||
return (
|
||||
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
|
||||
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||
style={isZigbee
|
||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||
: { background: '#a855f722', color: '#a855f7' }}
|
||||
>
|
||||
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
|
||||
{isZigbee ? 'Zigbee' : 'IP'}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||
{r.devices_found} found
|
||||
</span>
|
||||
{r.status === 'running' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
aria-label="Stop scan"
|
||||
onClick={() => handleStop(r.id)}
|
||||
disabled={stopping === r.id}
|
||||
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{stopping === r.id
|
||||
? <Loader2 size={13} className="animate-spin" />
|
||||
: <StopCircle size={13} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta grid */}
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
|
||||
<Meta
|
||||
label="Finished"
|
||||
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
|
||||
/>
|
||||
<Meta label="Duration" value={runDuration(r, now)} mono />
|
||||
<Meta label="Devices" value={`${r.devices_found}`} mono />
|
||||
</div>
|
||||
|
||||
{r.ranges.length > 0 && (
|
||||
<div className="mt-2 text-[11px]">
|
||||
<span className="text-muted-foreground">Ranges: </span>
|
||||
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{r.error && (
|
||||
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
||||
{r.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
|
||||
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
readAlignmentSettings,
|
||||
writeAlignmentSettings,
|
||||
subscribeAlignmentSettings,
|
||||
} from '@/utils/alignmentSettings'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||
const [interval, setIntervalValue] = useState(60)
|
||||
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||
const [serviceInterval, setServiceInterval] = useState(300)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || STANDALONE) return
|
||||
settingsApi.get()
|
||||
.then((res) => {
|
||||
setIntervalValue(res.data.interval_seconds)
|
||||
setServiceCheckEnabled(res.data.service_check_enabled)
|
||||
setServiceInterval(res.data.service_check_interval)
|
||||
})
|
||||
.catch(() => {/* use default */})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||
|
||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||
const next = { ...alignment, ...patch }
|
||||
setAlignment(next)
|
||||
writeAlignmentSettings(next)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
||||
// status-check interval needs an API round-trip.
|
||||
if (STANDALONE) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await settingsApi.save({
|
||||
interval_seconds: interval,
|
||||
service_check_enabled: serviceCheckEnabled,
|
||||
service_check_interval: serviceInterval,
|
||||
})
|
||||
toast.success('Settings saved')
|
||||
onClose()
|
||||
} catch {
|
||||
toast.error('Failed to save settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Status checker */}
|
||||
{!STANDALONE && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={interval}
|
||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
How often node health is polled (ping, HTTP, SSH…)
|
||||
</p>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
|
||||
<span className="text-xs text-foreground">Check services individually</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={serviceCheckEnabled}
|
||||
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle per-service status checks"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={30}
|
||||
max={3600}
|
||||
value={serviceInterval}
|
||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
|
||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||
aria-label="Service check interval"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Probes each service port. Offline services turn red. Default 300s (5 min).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alignment.enabled}
|
||||
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle alignment guides"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Hide IP addresses</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideIp}
|
||||
onChange={(e) => setHideIp(e.target.checked)}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle IP address masking"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={16}
|
||||
step={1}
|
||||
value={alignment.threshold}
|
||||
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Alignment snap threshold"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
||||
|
||||
describe('ConfirmAddToGroupModal', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
render(
|
||||
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
)
|
||||
expect(screen.queryByText('Add to group')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows node and group labels when open', () => {
|
||||
render(
|
||||
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText('Router')).toBeDefined()
|
||||
expect(screen.getByText('DMZ')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls onConfirm when the confirm button is clicked', () => {
|
||||
const onConfirm = vi.fn()
|
||||
render(
|
||||
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
||||
expect(onConfirm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onCancel when the cancel button is clicked', () => {
|
||||
const onCancel = vi.fn()
|
||||
render(
|
||||
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses container wording when variant is container', () => {
|
||||
render(
|
||||
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
|
||||
expect(screen.queryByText('Add to group')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DesignModal } from '../DesignModal'
|
||||
import { DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
function renderModal(props: Partial<Parameters<typeof DesignModal>[0]> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
render(<DesignModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
describe('DesignModal', () => {
|
||||
it('creates with the typed name and default icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('submits the selected icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
|
||||
})
|
||||
|
||||
it('trims whitespace and blocks empty names', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
// Empty → submit disabled, no call.
|
||||
const submit = screen.getByRole('button', { name: 'Create' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
|
||||
fireEvent.click(submit)
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('prefills name and icon in edit mode', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { name: 'Existing', icon: 'server' },
|
||||
title: 'Edit Canvas',
|
||||
submitLabel: 'Save',
|
||||
})
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Existing')
|
||||
// The server icon button is pre-selected.
|
||||
expect(screen.getByRole('button', { name: 'Server' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Existing', icon: 'server' })
|
||||
})
|
||||
|
||||
it('submits on Enter from the name field', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
const input = screen.getByLabelText('Name')
|
||||
fireEvent.change(input, { target: { value: 'Quick' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('calls onClose from Cancel', () => {
|
||||
const { onClose, onSubmit } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
|
||||
it('calls onSubmit with label when filled', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
||||
})
|
||||
|
||||
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
|
||||
})
|
||||
|
||||
it('trims surrounding whitespace/blank lines from label on submit', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
|
||||
})
|
||||
|
||||
it('renders the label field as a textarea (multi-line input)', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
it('omits label from payload when empty', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
|
||||
|
||||
it('pre-fills label from initial prop', () => {
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
||||
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
|
||||
const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
|
||||
expect(input.value).toBe('trunk')
|
||||
})
|
||||
|
||||
|
||||
@@ -353,6 +353,48 @@ describe('NodeModal', () => {
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for a plain node when a container-mode candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('still hides Parent Container for a plain node when the candidate is not in container mode', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Parent Container for an already-nested plain node so it can be detached', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps a container-mode parent_id on submit for a plain node', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: true }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('px1')
|
||||
})
|
||||
|
||||
it('drops a parent_id that is not a valid container on submit', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { ...BASE, type: 'server', parent_id: 'px1' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox', container_mode: false }],
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||
|
||||
@@ -99,6 +99,14 @@ describe('PendingDevicesModal', () => {
|
||||
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes via the X button (routes through DialogClose, not a raw onClick)', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<PendingDevicesModal open onClose={onClose} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
@@ -242,4 +250,25 @@ describe('PendingDevicesModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||
})
|
||||
|
||||
it('Enter confirms approve in pending select mode', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a']))
|
||||
expect(mockBulkRestore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Enter restores (not approves) in hidden select mode', async () => {
|
||||
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, status: 'hidden' }] })
|
||||
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||
expect(mockBulkApprove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
+75
-72
@@ -1,21 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import { ScanHistoryModal } from '../ScanHistoryModal'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/stores/canvasStore', () => ({
|
||||
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
|
||||
}))
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn(),
|
||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
stop: vi.fn(),
|
||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||
},
|
||||
settingsApi: { get: vi.fn(), save: vi.fn() },
|
||||
}))
|
||||
|
||||
import { scanApi } from '@/api/client'
|
||||
@@ -24,9 +20,10 @@ import { toast } from 'sonner'
|
||||
const RUNNING_RUN = {
|
||||
id: 'run-1',
|
||||
status: 'running',
|
||||
kind: 'ip',
|
||||
ranges: ['192.168.1.0/24'],
|
||||
devices_found: 2,
|
||||
started_at: new Date().toISOString(),
|
||||
started_at: new Date(Date.now() - 5000).toISOString(),
|
||||
finished_at: null,
|
||||
error: null,
|
||||
}
|
||||
@@ -34,16 +31,18 @@ const RUNNING_RUN = {
|
||||
const DONE_RUN = {
|
||||
id: 'run-2',
|
||||
status: 'done',
|
||||
kind: 'ip',
|
||||
ranges: ['192.168.1.0/24'],
|
||||
devices_found: 3,
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
started_at: new Date(Date.now() - 60000).toISOString(),
|
||||
finished_at: new Date(Date.now() - 30000).toISOString(),
|
||||
error: null,
|
||||
}
|
||||
|
||||
const CANCELLED_RUN = {
|
||||
id: 'run-3',
|
||||
status: 'cancelled',
|
||||
kind: 'ip',
|
||||
ranges: ['192.168.1.0/24'],
|
||||
devices_found: 1,
|
||||
started_at: new Date().toISOString(),
|
||||
@@ -51,36 +50,26 @@ const CANCELLED_RUN = {
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
hasUnsavedChanges: false,
|
||||
hideIp: false,
|
||||
toggleHideIp: vi.fn(),
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
const ZIGBEE_RUN = {
|
||||
id: 'run-4',
|
||||
status: 'done',
|
||||
kind: 'zigbee',
|
||||
ranges: [],
|
||||
devices_found: 7,
|
||||
started_at: new Date().toISOString(),
|
||||
finished_at: new Date().toISOString(),
|
||||
error: null,
|
||||
}
|
||||
|
||||
function renderModal() {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
onAddNode={vi.fn()}
|
||||
onAddGroupRect={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onNodeApproved={vi.fn()}
|
||||
/>
|
||||
<ScanHistoryModal open onClose={vi.fn()} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
async function openHistory() {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan History' }))
|
||||
// Wait for runs to load
|
||||
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
||||
}
|
||||
|
||||
describe('ScanHistoryPanel — stop scan', () => {
|
||||
describe('ScanHistoryModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
@@ -88,68 +77,82 @@ describe('ScanHistoryPanel — stop scan', () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
|
||||
})
|
||||
|
||||
it('shows stop button only for running scans', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||
renderSidebar()
|
||||
await openHistory()
|
||||
|
||||
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
||||
|
||||
// Exactly one stop button rendered (for the running scan only)
|
||||
const stopButtons = screen.getAllByRole('button', { name: 'Stop scan' })
|
||||
expect(stopButtons).toHaveLength(1)
|
||||
it('loads runs when opened', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(scanApi.runs).toHaveBeenCalled())
|
||||
expect(await screen.findByText('done')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls scanApi.stop with the correct run ID on click', async () => {
|
||||
it('shows empty state when no scans', async () => {
|
||||
renderModal()
|
||||
expect(await screen.findByText('No scans yet')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows stop button only for running scans', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getByText('running')).toBeDefined())
|
||||
expect(screen.getAllByRole('button', { name: 'Stop scan' })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('calls scanApi.stop with the correct run ID', async () => {
|
||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||
renderSidebar()
|
||||
await openHistory()
|
||||
|
||||
renderModal()
|
||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||
fireEvent.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scanApi.stop).toHaveBeenCalledWith('run-1')
|
||||
})
|
||||
await waitFor(() => expect(scanApi.stop).toHaveBeenCalledWith('run-1'))
|
||||
})
|
||||
|
||||
it('shows success toast when stop succeeds', async () => {
|
||||
vi.mocked(scanApi.stop).mockResolvedValue({ data: { stopping: true } } as never)
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||
renderSidebar()
|
||||
await openHistory()
|
||||
|
||||
renderModal()
|
||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||
fireEvent.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith('Scan stop requested')
|
||||
})
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Scan stop requested'))
|
||||
})
|
||||
|
||||
it('shows error toast when stop fails', async () => {
|
||||
vi.mocked(scanApi.stop).mockRejectedValue(new Error('network'))
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN] } as never)
|
||||
renderSidebar()
|
||||
await openHistory()
|
||||
|
||||
renderModal()
|
||||
const stopBtn = await screen.findByRole('button', { name: 'Stop scan' })
|
||||
fireEvent.click(stopBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to stop scan')
|
||||
})
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Failed to stop scan'))
|
||||
})
|
||||
|
||||
it('renders cancelled status without stop button or spinner', async () => {
|
||||
it('renders cancelled status without a stop button', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [CANCELLED_RUN] } as never)
|
||||
renderSidebar()
|
||||
await openHistory()
|
||||
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getByText('cancelled')).toBeDefined())
|
||||
|
||||
// No stop button
|
||||
expect(screen.queryByRole('button', { name: 'Stop scan' })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows duration for a finished run', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN] } as never)
|
||||
renderModal()
|
||||
// DONE_RUN ran 30s
|
||||
expect(await screen.findByText('30s')).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters by status', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [RUNNING_RUN, DONE_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Running' }))
|
||||
expect(screen.queryByText('done')).toBeNull()
|
||||
expect(screen.getByText('running')).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters by kind', async () => {
|
||||
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, ZIGBEE_RUN] } as never)
|
||||
renderModal()
|
||||
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||
// Only the zigbee run (7 found) remains
|
||||
expect(screen.getByText('7 found')).toBeDefined()
|
||||
expect(screen.queryByText('3 found')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { SettingsModal } from '../SettingsModal'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/api/client', () => ({
|
||||
settingsApi: {
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
|
||||
it('loads interval from API when opened', async () => {
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
|
||||
expect(screen.getByText('Status check interval (s)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not fetch when closed', () => {
|
||||
render(<SettingsModal open={false} onClose={vi.fn()} />)
|
||||
expect(settingsApi.get).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays interval loaded from API', async () => {
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
expect(await screen.findByDisplayValue('120')).toBeDefined()
|
||||
})
|
||||
|
||||
it('saves interval and closes on Save click', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SettingsModal open onClose={onClose} />)
|
||||
const input = await screen.findByDisplayValue('60')
|
||||
fireEvent.change(input, { target: { value: '180' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180, service_check_enabled: false, service_check_interval: 300 })
|
||||
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast and stays open when save fails', async () => {
|
||||
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
|
||||
const onClose = vi.fn()
|
||||
render(<SettingsModal open onClose={onClose} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
|
||||
})
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reflects and persists the hide-IP preference', async () => {
|
||||
useCanvasStore.setState({ hideIp: false })
|
||||
localStorage.removeItem('homelable.hideIp')
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
const checkbox = screen.getByLabelText('Toggle IP address masking') as HTMLInputElement
|
||||
expect(checkbox.checked).toBe(false)
|
||||
fireEvent.click(checkbox)
|
||||
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||
})
|
||||
|
||||
it('loads and toggles the per-service check setting, saving its interval', async () => {
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: true, service_check_interval: 600 } } as never)
|
||||
render(<SettingsModal open onClose={vi.fn()} />)
|
||||
const toggle = await screen.findByLabelText('Toggle per-service status checks') as HTMLInputElement
|
||||
// The toggle label renders immediately; its checked state only flips once
|
||||
// settingsApi.get() resolves, so wait for that before asserting.
|
||||
await waitFor(() => expect(toggle.checked).toBe(true))
|
||||
expect(await screen.findByDisplayValue('600')).toBeDefined()
|
||||
|
||||
fireEvent.click(toggle) // disable
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 60, service_check_enabled: false, service_check_interval: 600 })
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onClose on Cancel', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<SettingsModal open onClose={onClose} />)
|
||||
await screen.findByDisplayValue('60')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createElement, useState } from 'react'
|
||||
import { createElement, useRef, useState } from 'react'
|
||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type ServiceStatus, type NodeData, type NodeProperty } from '@/types'
|
||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
@@ -21,7 +21,8 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool
|
||||
const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true }
|
||||
|
||||
export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup } = useCanvasStore()
|
||||
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
|
||||
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||
|
||||
const [addingForNode, setAddingForNode] = useState<string | null>(null)
|
||||
const [newSvc, setNewSvc] = useState<SvcForm>(EMPTY_FORM)
|
||||
@@ -64,6 +65,9 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
node={node}
|
||||
nodes={nodes}
|
||||
onUngroup={() => { ungroup(node.id) }}
|
||||
onRemoveChild={(id) => { snapshotHistory(); removeFromGroup(node.id, id) }}
|
||||
onChangeDescription={(value) => updateNode(node.id, { notes: value })}
|
||||
onSnapshotBeforeEdit={snapshotHistory}
|
||||
onToggleBorder={() => {
|
||||
snapshotHistory()
|
||||
updateNode(node.id, {
|
||||
@@ -251,6 +255,13 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||
</div>
|
||||
|
||||
{/* Size section — manual width/height entry for pixel-exact sizing */}
|
||||
<SizeFields
|
||||
key={node.id}
|
||||
node={node}
|
||||
onCommit={(size) => { snapshotHistory(); setNodeSize(node.id, size) }}
|
||||
/>
|
||||
|
||||
{/* Properties section */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
@@ -314,7 +325,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
editingIndex === i ? (
|
||||
<ServiceForm key={`edit-${i}`} form={editSvc} onChange={setEditSvc} onConfirm={handleSaveEdit} onCancel={() => setEditingFor(null)} confirmLabel="Save" autoFocus />
|
||||
) : (
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
<ServiceBadge key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`} svc={svc} host={host} status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]} onEdit={() => handleStartEdit(i)} onRemove={() => handleRemoveService(i)} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
@@ -341,6 +352,79 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Size fields (manual width/height entry) ---
|
||||
|
||||
const round = (v: number | undefined): string => (v != null ? String(Math.round(v)) : '')
|
||||
|
||||
function SizeFields({ node, onCommit }: { node: Node<NodeData>; onCommit: (size: { width?: number; height?: number }) => void }) {
|
||||
// Live size from the explicit dimension, falling back to the DOM-measured
|
||||
// size so the field shows the node's current footprint even before resize.
|
||||
const liveWidth = round(node.width ?? node.measured?.width)
|
||||
const liveHeight = round(node.height ?? node.measured?.height)
|
||||
const [width, setWidth] = useState(liveWidth)
|
||||
const [height, setHeight] = useState(liveHeight)
|
||||
|
||||
// Resync the fields when the node is resized by dragging its corner handle
|
||||
// (which mutates node.width/height in the store). Adjusting state during
|
||||
// render — the React-blessed alternative to an effect — keeps it in step
|
||||
// without cascading renders. The field the user is actively editing is left
|
||||
// alone so we don't clobber their keystrokes mid-type.
|
||||
const [editing, setEditing] = useState<'width' | 'height' | null>(null)
|
||||
const [prev, setPrev] = useState({ w: liveWidth, h: liveHeight })
|
||||
if (prev.w !== liveWidth || prev.h !== liveHeight) {
|
||||
setPrev({ w: liveWidth, h: liveHeight })
|
||||
if (editing !== 'width') setWidth(liveWidth)
|
||||
if (editing !== 'height') setHeight(liveHeight)
|
||||
}
|
||||
|
||||
const commit = (raw: string, axis: 'width' | 'height') => {
|
||||
const n = Number(raw)
|
||||
if (!raw.trim() || Number.isNaN(n) || n <= 0) {
|
||||
// Reset the field to the live value on invalid input — no mutation.
|
||||
if (axis === 'width') setWidth(round(node.width ?? node.measured?.width))
|
||||
else setHeight(round(node.height ?? node.measured?.height))
|
||||
return
|
||||
}
|
||||
onCommit({ [axis]: n })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">Size</span>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<label className="flex flex-1 items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/70 w-3">W</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={140}
|
||||
aria-label="Width"
|
||||
value={width}
|
||||
onFocus={() => setEditing('width')}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
onBlur={() => { setEditing(null); commit(width, 'width') }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-1 items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/70 w-3">H</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={50}
|
||||
aria-label="Height"
|
||||
value={height}
|
||||
onFocus={() => setEditing('height')}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
onBlur={() => { setEditing(null); commit(height, 'height') }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }}
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Multi-select panel ---
|
||||
|
||||
interface MultiSelectPanelProps {
|
||||
@@ -425,17 +509,33 @@ interface GroupDetailPanelProps {
|
||||
node: Node<NodeData>
|
||||
nodes: Node<NodeData>[]
|
||||
onUngroup: () => void
|
||||
onRemoveChild: (id: string) => void
|
||||
onChangeDescription: (value: string) => void
|
||||
onSnapshotBeforeEdit: () => void
|
||||
onToggleBorder: () => void
|
||||
onClose: () => void
|
||||
onSelectChild: (id: string) => void
|
||||
}
|
||||
|
||||
function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
||||
function GroupDetailPanel({ node, nodes, onUngroup, onRemoveChild, onChangeDescription, onSnapshotBeforeEdit, onToggleBorder, onClose, onSelectChild }: GroupDetailPanelProps) {
|
||||
const children = nodes.filter((n) => n.parentId === node.id)
|
||||
const onlineCount = children.filter((n) => n.data.status === 'online').length
|
||||
const offlineCount = children.filter((n) => n.data.status === 'offline').length
|
||||
const showBorder = node.data.custom_colors?.show_border !== false
|
||||
|
||||
// Description reuses data.notes, which already round-trips to the backend.
|
||||
// Controlled + committed on every keystroke so ANY save path (incl. Ctrl+S,
|
||||
// which never blurs the field) captures it. History is snapshotted once at the
|
||||
// start of an edit session so the whole edit is a single undo step.
|
||||
const snappedRef = useRef(false)
|
||||
const handleDescriptionChange = (value: string) => {
|
||||
if (!snappedRef.current) {
|
||||
onSnapshotBeforeEdit()
|
||||
snappedRef.current = true
|
||||
}
|
||||
onChangeDescription(value)
|
||||
}
|
||||
|
||||
const handleUngroup = () => {
|
||||
if (confirm(`Ungroup "${node.data.label}"? Nodes will be released to the canvas.`)) {
|
||||
onUngroup()
|
||||
@@ -461,20 +561,48 @@ function GroupDetailPanel({ node, nodes, onUngroup, onToggleBorder, onClose, onS
|
||||
{offlineCount > 0 && <span style={{ color: STATUS_COLORS.offline }}>● {offlineCount} offline</span>}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<label htmlFor="group-description" className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="group-description"
|
||||
value={node.data.notes ?? ''}
|
||||
onFocus={() => { snappedRef.current = false }}
|
||||
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||
placeholder="Add a description for this group…"
|
||||
rows={3}
|
||||
className="mt-1.5 w-full resize-y rounded-md bg-[#21262d] border border-[#30363d] px-2 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Children list */}
|
||||
<div className="flex-1 px-4 py-3 space-y-1.5 overflow-y-auto">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">Members</span>
|
||||
{children.length === 0 && <p className="text-xs text-muted-foreground/50">No nodes in this group.</p>}
|
||||
{children.map((child) => (
|
||||
<button
|
||||
<div
|
||||
key={child.id}
|
||||
onClick={() => onSelectChild(child.id)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors text-left"
|
||||
className="group/member w-full flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs hover:bg-[#30363d] transition-colors"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||||
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectChild(child.id)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left cursor-pointer"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: STATUS_COLORS[child.data.status] }} />
|
||||
<span className="truncate text-foreground font-medium">{child.data.label}</span>
|
||||
<span className="ml-auto text-muted-foreground shrink-0">{NODE_TYPE_LABELS[child.data.type] ?? child.data.type}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveChild(child.id)}
|
||||
aria-label={`Remove ${child.data.label} from group`}
|
||||
title="Remove from group"
|
||||
className="shrink-0 opacity-0 group-hover/member:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] cursor-pointer"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -673,9 +801,13 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
web: '#00d4ff', database: '#a855f7', monitoring: '#39d353', storage: '#e3b341', security: '#f85149', remote: '#8b949e',
|
||||
}
|
||||
|
||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||
function ServiceBadge({ svc, host, status, onEdit, onRemove }: { svc: ServiceInfo; host?: string; status?: ServiceStatus; onEdit: () => void; onRemove: () => void }) {
|
||||
const url = getServiceUrl(svc, host)
|
||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||
// Manually-added services carry no category, so they fell back to grey even
|
||||
// when they're reachable HTTP/HTTPS. Treat any resolvable web URL as `web`.
|
||||
const categoryColor = CATEGORY_COLORS[svc.category ?? ''] ?? (url ? CATEGORY_COLORS.web : '#8b949e')
|
||||
// A live offline service overrides the category colour with red.
|
||||
const color = status === 'offline' ? '#f85149' : categoryColor
|
||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,39 +1,24 @@
|
||||
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 { useState, useCallback } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { designsApi } from '@/api/client'
|
||||
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||
import type { Design } from '@/types'
|
||||
import { toast } from 'sonner'
|
||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||
import {
|
||||
type AlignmentSettings,
|
||||
readAlignmentSettings,
|
||||
writeAlignmentSettings,
|
||||
subscribeAlignmentSettings,
|
||||
} from '@/utils/alignmentSettings'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||
|
||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||
]
|
||||
|
||||
interface ScanRun {
|
||||
id: string
|
||||
status: string
|
||||
kind?: string
|
||||
ranges: string[]
|
||||
devices_found: number
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onAddNode: () => void
|
||||
onAddGroupRect: () => void
|
||||
@@ -41,26 +26,47 @@ interface SidebarProps {
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onSave: () => void
|
||||
forceView?: SidebarView
|
||||
onOpenSettings: () => void
|
||||
onOpenHistory: () => void
|
||||
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
|
||||
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
setPrevForceView(forceView)
|
||||
if (forceView) {
|
||||
setActiveView(forceView)
|
||||
setCollapsed(false)
|
||||
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
||||
if (!designModal) return
|
||||
try {
|
||||
if (designModal.mode === 'create') {
|
||||
const res = await designsApi.create({ name: data.name, icon: data.icon })
|
||||
addDesign(res.data)
|
||||
} else if (designModal.design) {
|
||||
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
|
||||
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
|
||||
}
|
||||
setDesignModal(null)
|
||||
} catch {
|
||||
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
|
||||
}
|
||||
}
|
||||
}, [designModal, addDesign, updateDesign])
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
const handleDesignDelete = useCallback(async (d: Design) => {
|
||||
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
||||
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
|
||||
try {
|
||||
await designsApi.delete(d.id)
|
||||
removeDesign(d.id)
|
||||
toast.success('Canvas deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete canvas')
|
||||
}
|
||||
}, [designs.length, removeDesign])
|
||||
|
||||
const { nodes, hasUnsavedChanges } = useCanvasStore()
|
||||
|
||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
@@ -88,14 +94,82 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<Logo size={28} showText={!collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Design Switcher */}
|
||||
{!collapsed && designs.length > 0 && (
|
||||
<div className="px-2 pt-2 pb-1 border-b border-border relative">
|
||||
<button
|
||||
onClick={() => setDesignSwitcherOpen((o) => !o)}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
|
||||
>
|
||||
{activeDesignId ? (() => {
|
||||
const active = designs.find((d) => d.id === activeDesignId)
|
||||
const Icon = resolveDesignIcon(active?.icon)
|
||||
return <><Icon size={14} className="shrink-0 text-[#00d4ff]" /><span className="truncate text-foreground">{active?.name ?? 'Select Canvas'}</span></>
|
||||
})() : <span className="text-muted-foreground">Select Canvas</span>}
|
||||
</button>
|
||||
{designSwitcherOpen && (
|
||||
<>
|
||||
{/* Overlay to close */}
|
||||
<div className="fixed inset-0 z-40" onClick={() => setDesignSwitcherOpen(false)} />
|
||||
<div className="absolute left-2 right-2 top-full mt-1 z-50 bg-[#21262d] border border-border rounded-md shadow-xl overflow-hidden">
|
||||
{designs.map((d) => {
|
||||
const Icon = resolveDesignIcon(d.icon)
|
||||
const isActive = d.id === activeDesignId
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`group flex items-center transition-colors ${
|
||||
isActive ? 'bg-[#00d4ff]/10 text-[#00d4ff]' : 'text-muted-foreground hover:bg-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setActiveDesign(d.id); setDesignSwitcherOpen(false) }}
|
||||
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-xs cursor-pointer hover:text-foreground"
|
||||
>
|
||||
<Icon size={14} className="shrink-0" />
|
||||
<span className="truncate">{d.name}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Edit ${d.name}`}
|
||||
title="Edit canvas"
|
||||
onClick={() => { setDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
|
||||
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Delete ${d.name}`}
|
||||
title="Delete canvas"
|
||||
disabled={designs.length <= 1}
|
||||
onClick={() => handleDesignDelete(d)}
|
||||
className="shrink-0 p-1.5 pr-2 text-muted-foreground hover:text-[#f85149] cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-0"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="border-t border-border" />
|
||||
<button
|
||||
onClick={() => { setDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
<span>New Canvas</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Views */}
|
||||
<nav className="flex flex-col gap-0.5 p-2">
|
||||
<SidebarItem
|
||||
icon={LayoutDashboard}
|
||||
label="Canvas"
|
||||
collapsed={collapsed}
|
||||
active={activeView === 'canvas'}
|
||||
onClick={() => setActiveView('canvas')}
|
||||
active
|
||||
/>
|
||||
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||
<SidebarItem
|
||||
@@ -111,24 +185,12 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
icon={Clock}
|
||||
label="Scan History"
|
||||
collapsed={collapsed}
|
||||
active={activeView === 'history'}
|
||||
onClick={() => setActiveView('history')}
|
||||
onClick={onOpenHistory}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* View content (only when expanded) */}
|
||||
{!collapsed && activeView !== 'canvas' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||
{activeView === 'history' && <ScanHistoryPanel />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats (only on canvas view) */}
|
||||
{!collapsed && activeView === 'canvas' && (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
{!collapsed && <div className="flex-1" />}
|
||||
|
||||
{/* Stats footer */}
|
||||
{!collapsed && (
|
||||
@@ -155,30 +217,20 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||
<SidebarItem
|
||||
icon={hideIp ? EyeOff : Eye}
|
||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||
collapsed={collapsed}
|
||||
onClick={toggleHideIp}
|
||||
active={hideIp}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={Save}
|
||||
label="Save Canvas"
|
||||
collapsed={collapsed}
|
||||
onClick={onSave}
|
||||
onClick={() => onSave()}
|
||||
badge={hasUnsavedChanges}
|
||||
accent
|
||||
/>
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
collapsed={collapsed}
|
||||
active={activeView === 'settings'}
|
||||
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
|
||||
/>
|
||||
)}
|
||||
<SidebarItem
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
collapsed={collapsed}
|
||||
onClick={onOpenSettings}
|
||||
/>
|
||||
{!STANDALONE && (
|
||||
<SidebarItem
|
||||
icon={LogOut}
|
||||
@@ -190,239 +242,22 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
</div>
|
||||
|
||||
{!collapsed && <VersionBadge />}
|
||||
|
||||
<DesignModal
|
||||
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
|
||||
open={!!designModal}
|
||||
onClose={() => setDesignModal(null)}
|
||||
onSubmit={handleDesignSubmit}
|
||||
initial={designModal?.mode === 'edit' && designModal.design
|
||||
? { name: designModal.design.name, icon: designModal.design.icon ?? DEFAULT_DESIGN_ICON }
|
||||
: undefined}
|
||||
title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'}
|
||||
submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ScanHistoryPanel() {
|
||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const prevRunsRef = useRef<ScanRun[]>([])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await scanApi.runs()
|
||||
const next: ScanRun[] = res.data
|
||||
|
||||
// Surface transitions and refresh dependent UI
|
||||
for (const run of next) {
|
||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||
if (prev?.status === 'running' && run.status === 'error') {
|
||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||
}
|
||||
if (prev?.status === 'running' && run.status === 'done') {
|
||||
if (run.kind === 'zigbee') {
|
||||
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||
}
|
||||
// Notify pending modal/canvas to refresh
|
||||
useCanvasStore.getState().notifyScanDeviceFound()
|
||||
}
|
||||
}
|
||||
prevRunsRef.current = next
|
||||
setRuns(next)
|
||||
} catch {
|
||||
toast.error('Failed to load scan history')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial load
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// Auto-refresh every 3s while any run is still running
|
||||
useEffect(() => {
|
||||
const hasRunning = runs.some((r) => r.status === 'running')
|
||||
if (!hasRunning) return
|
||||
const id = setInterval(load, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [runs, load])
|
||||
|
||||
const [stopping, setStopping] = useState<string | null>(null)
|
||||
|
||||
const handleStop = async (runId: string) => {
|
||||
setStopping(runId)
|
||||
try {
|
||||
await scanApi.stop(runId)
|
||||
toast.success('Scan stop requested')
|
||||
} catch {
|
||||
toast.error('Failed to stop scan')
|
||||
} finally {
|
||||
setStopping(null)
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = (s: string) =>
|
||||
s === 'done' ? '#39d353'
|
||||
: s === 'running' ? '#e3b341'
|
||||
: s === 'error' ? '#f85149'
|
||||
: s === 'cancelled' ? '#8b949e'
|
||||
: '#8b949e'
|
||||
|
||||
return (
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">History</span>
|
||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{loading && runs.length === 0 && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
||||
{!loading && runs.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">No scans yet</p>
|
||||
)}
|
||||
{runs.map((r) => (
|
||||
<div key={r.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
||||
<span
|
||||
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
||||
style={r.kind === 'zigbee'
|
||||
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||
: { background: '#a855f722', color: '#a855f7' }}
|
||||
>
|
||||
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
||||
</span>
|
||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||
{r.status === 'running' && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<button
|
||||
aria-label="Stop scan"
|
||||
onClick={() => handleStop(r.id)}
|
||||
disabled={stopping === r.id}
|
||||
className="p-0.5 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{stopping === r.id
|
||||
? <Loader2 size={11} className="animate-spin" />
|
||||
: <StopCircle size={11} />
|
||||
}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{new Date(r.started_at.endsWith('Z') ? r.started_at : r.started_at + 'Z').toLocaleString()}
|
||||
</div>
|
||||
{r.ranges.length > 0 && (
|
||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||
)}
|
||||
{r.error && (
|
||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
||||
{r.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsPanel() {
|
||||
const [interval, setIntervalValue] = useState(60)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsApi.get()
|
||||
.then((res) => setIntervalValue(res.data.interval_seconds))
|
||||
.catch(() => {/* use default */})
|
||||
}, [])
|
||||
|
||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||
|
||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||
const next = { ...alignment, ...patch }
|
||||
setAlignment(next)
|
||||
writeAlignmentSettings(next)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await settingsApi.save({ interval_seconds: interval })
|
||||
toast.success('Settings saved')
|
||||
} catch {
|
||||
toast.error('Failed to save settings')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 space-y-4">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Settings</span>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={10}
|
||||
max={3600}
|
||||
value={interval}
|
||||
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">seconds</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
How often node health is polled (ping, HTTP, SSH…)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="w-full py-1.5 rounded-md text-xs font-medium bg-[#00d4ff]/10 text-[#00d4ff] border border-[#00d4ff]/30 hover:bg-[#00d4ff]/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||
|
||||
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alignment.enabled}
|
||||
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||
className="cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Toggle alignment guides"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={2}
|
||||
max={16}
|
||||
step={1}
|
||||
value={alignment.threshold}
|
||||
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||
aria-label="Alignment snap threshold"
|
||||
/>
|
||||
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionBadge() {
|
||||
const current = __APP_VERSION__
|
||||
const { latest, hasUpdate } = useLatestRelease(current)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react'
|
||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload } from 'lucide-react'
|
||||
import { Save, LayoutDashboard, Download, Palette, Undo2, Redo2, HelpCircle, Table2, FileDown, Upload, Eye } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
@@ -15,9 +15,10 @@ interface ToolbarProps {
|
||||
onExportMd: () => void
|
||||
onExportYaml: () => void
|
||||
onImportYaml: (content: string) => void
|
||||
onViewOnly: () => void
|
||||
}
|
||||
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml }: ToolbarProps) {
|
||||
export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, onRedo, onShortcuts, onExportMd, onExportYaml, onImportYaml, onViewOnly }: ToolbarProps) {
|
||||
const { hasUnsavedChanges, past, future } = useCanvasStore()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
@@ -81,6 +82,9 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||
<Table2 size={14} /> MD
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
||||
<Eye size={14} /> View
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||
<HelpCircle size={14} />
|
||||
</Button>
|
||||
@@ -91,7 +95,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||
}}
|
||||
onClick={onSave}
|
||||
onClick={() => onSave()}
|
||||
>
|
||||
{hasUnsavedChanges && (
|
||||
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-[#e3b341] border border-[#161b22]" />
|
||||
|
||||
@@ -5,7 +5,10 @@ import * as canvasStore from '@/stores/canvasStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('@/stores/canvasStore', async (importActual) => ({
|
||||
...(await importActual<typeof canvasStore>()),
|
||||
useCanvasStore: vi.fn(),
|
||||
}))
|
||||
|
||||
function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
return {
|
||||
@@ -22,8 +25,8 @@ function makeNode(data: Partial<NodeData>): Node<NodeData> {
|
||||
}
|
||||
}
|
||||
|
||||
function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
function setupStore(nodeData: Partial<NodeData> = {}, serviceStatuses: Record<string, string> = {}) {
|
||||
const state = {
|
||||
nodes: [makeNode(nodeData)],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
@@ -33,7 +36,13 @@ function setupStore(nodeData: Partial<NodeData> = {}) {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
setNodeSize: vi.fn(),
|
||||
serviceStatuses,
|
||||
}
|
||||
// Support both the bare destructure call and the selector-based call.
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DetailPanel', () => {
|
||||
@@ -502,6 +511,43 @@ describe('DetailPanel', () => {
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||
})
|
||||
|
||||
it('colors a categoryless but reachable web service blue, not grey', () => {
|
||||
setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const link = screen.getByRole('link', { name: 'nginx' })
|
||||
expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||
})
|
||||
|
||||
it('keeps a categoryless unreachable service grey', () => {
|
||||
setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e
|
||||
})
|
||||
|
||||
it('respects an explicit category over the url fallback', () => {
|
||||
setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database)
|
||||
})
|
||||
|
||||
it('paints a service red when its live status is offline', () => {
|
||||
setupStore(
|
||||
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||
{ 'n1:8080/tcp': 'offline' },
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
|
||||
})
|
||||
|
||||
it('keeps the category colour when the live status is online', () => {
|
||||
setupStore(
|
||||
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
|
||||
{ 'n1:8080/tcp': 'online' },
|
||||
)
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Last Seen formatting', () => {
|
||||
@@ -526,4 +572,69 @@ describe('DetailPanel', () => {
|
||||
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Size section', () => {
|
||||
function setupSized(node: Partial<Node<NodeData>>, setNodeSize = vi.fn()) {
|
||||
const state = {
|
||||
nodes: [{ ...makeNode({}), ...node }],
|
||||
selectedNodeId: 'n1',
|
||||
selectedNodeIds: [],
|
||||
setSelectedNode: vi.fn(),
|
||||
deleteNode: vi.fn(),
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
setNodeSize,
|
||||
serviceStatuses: {},
|
||||
}
|
||||
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
|
||||
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
|
||||
)
|
||||
return setNodeSize
|
||||
}
|
||||
|
||||
it('shows the current width/height, preferring explicit over measured', () => {
|
||||
setupSized({ width: 220, height: 130, measured: { width: 999, height: 999 } })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('220')
|
||||
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('130')
|
||||
})
|
||||
|
||||
it('falls back to the measured size before a resize', () => {
|
||||
setupSized({ measured: { width: 187, height: 73 } })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('187')
|
||||
expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('73')
|
||||
})
|
||||
|
||||
it('commits a manual width on blur', () => {
|
||||
const setNodeSize = setupSized({ width: 200, height: 100 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const w = screen.getByLabelText('Width')
|
||||
fireEvent.change(w, { target: { value: '260' } })
|
||||
fireEvent.blur(w)
|
||||
expect(setNodeSize).toHaveBeenCalledWith('n1', { width: 260 })
|
||||
})
|
||||
|
||||
it('reverts invalid input without committing', () => {
|
||||
const setNodeSize = setupSized({ width: 200, height: 100 })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
const w = screen.getByLabelText('Width') as HTMLInputElement
|
||||
fireEvent.change(w, { target: { value: 'abc' } })
|
||||
fireEvent.blur(w)
|
||||
expect(setNodeSize).not.toHaveBeenCalled()
|
||||
expect(w.value).toBe('200')
|
||||
})
|
||||
|
||||
it('resyncs the field when the node is resized by corner drag', () => {
|
||||
setupSized({ width: 200, height: 100 })
|
||||
const { rerender } = render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('200')
|
||||
// Simulate a corner-drag resize updating the store dimension.
|
||||
setupSized({ width: 340, height: 100 })
|
||||
rerender(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('340')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,7 @@ const mockStore = {
|
||||
snapshotHistory: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
ungroup: vi.fn(),
|
||||
removeFromGroup: vi.fn(),
|
||||
}
|
||||
|
||||
function setupStore(overrides = {}) {
|
||||
@@ -230,4 +231,46 @@ describe('GroupDetailPanel', () => {
|
||||
fireEvent.click(screen.getByText('Child Node Alpha'))
|
||||
expect(setSelectedNode).toHaveBeenCalledWith('c1')
|
||||
})
|
||||
|
||||
it('removes a child from the group via the remove button', () => {
|
||||
const removeFromGroup = vi.fn()
|
||||
const snapshotHistory = vi.fn()
|
||||
const group = makeGroupNode()
|
||||
const child = makeNode('c1', { parentId: 'g1', data: { label: 'Router', type: 'router', status: 'online', services: [] } })
|
||||
setupStore({ nodes: [group, child], selectedNodeId: 'g1', selectedNodeIds: ['g1'], removeFromGroup, snapshotHistory })
|
||||
renderPanel()
|
||||
fireEvent.click(screen.getByRole('button', { name: /remove router from group/i }))
|
||||
expect(removeFromGroup).toHaveBeenCalledWith('g1', 'c1')
|
||||
expect(snapshotHistory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the existing group description from notes', () => {
|
||||
const group = makeGroupNode()
|
||||
group.data = { ...group.data, notes: 'Critical DMZ hosts' } as typeof group.data
|
||||
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'] })
|
||||
renderPanel()
|
||||
expect((screen.getByLabelText('Description') as HTMLTextAreaElement).value).toBe('Critical DMZ hosts')
|
||||
})
|
||||
|
||||
it('commits the description to the store on each change (so Ctrl+S captures it)', () => {
|
||||
const updateNode = vi.fn()
|
||||
const group = makeGroupNode()
|
||||
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode })
|
||||
renderPanel()
|
||||
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'New notes' } })
|
||||
expect(updateNode).toHaveBeenCalledWith('g1', { notes: 'New notes' })
|
||||
})
|
||||
|
||||
it('snapshots history once at the start of an edit, not on every keystroke', () => {
|
||||
const updateNode = vi.fn()
|
||||
const snapshotHistory = vi.fn()
|
||||
const group = makeGroupNode()
|
||||
setupStore({ nodes: [group], selectedNodeId: 'g1', selectedNodeIds: ['g1'], updateNode, snapshotHistory })
|
||||
renderPanel()
|
||||
const textarea = screen.getByLabelText('Description')
|
||||
fireEvent.change(textarea, { target: { value: 'a' } })
|
||||
fireEvent.change(textarea, { target: { value: 'ab' } })
|
||||
fireEvent.change(textarea, { target: { value: 'abc' } })
|
||||
expect(snapshotHistory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import * as canvasStore from '@/stores/canvasStore'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
trigger: vi.fn(),
|
||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||
getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
|
||||
},
|
||||
settingsApi: {
|
||||
get: vi.fn(),
|
||||
save: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { settingsApi } from '@/api/client'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function renderSidebar() {
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
hasUnsavedChanges: false,
|
||||
hideIp: false,
|
||||
toggleHideIp: vi.fn(),
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<Sidebar
|
||||
onAddNode={vi.fn()}
|
||||
onAddGroupRect={vi.fn()}
|
||||
onScan={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onNodeApproved={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('SettingsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } as never)
|
||||
vi.mocked(toast.success).mockReset()
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
|
||||
it('opens when Settings item is clicked', async () => {
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.get).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(screen.getByText('Status check interval (s)')).toBeDefined()
|
||||
})
|
||||
|
||||
it('displays interval loaded from API', async () => {
|
||||
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 120 } } as never)
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
const input = await screen.findByDisplayValue('120')
|
||||
expect(input).toBeDefined()
|
||||
})
|
||||
|
||||
it('saves interval via settingsApi on Save click', async () => {
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
const input = await screen.findByDisplayValue('60')
|
||||
fireEvent.change(input, { target: { value: '180' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.save).toHaveBeenCalledWith({ interval_seconds: 180 })
|
||||
expect(toast.success).toHaveBeenCalledWith('Settings saved')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error toast when save fails', async () => {
|
||||
vi.mocked(settingsApi.save).mockRejectedValue(new Error('network'))
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
await screen.findByDisplayValue('60')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to save settings')
|
||||
})
|
||||
})
|
||||
|
||||
it('closes panel when Settings is clicked again', async () => {
|
||||
renderSidebar()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
await screen.findByText('Status check interval (s)')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
expect(screen.queryByText('Status check interval (s)')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Sidebar } from '../Sidebar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
@@ -46,15 +46,12 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
|
||||
data: { label: id, type, status, services: [] },
|
||||
})
|
||||
|
||||
const mockToggleHideIp = vi.fn()
|
||||
const mockLogout = vi.fn()
|
||||
|
||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
nodes: [],
|
||||
hasUnsavedChanges: false,
|
||||
hideIp: false,
|
||||
toggleHideIp: mockToggleHideIp,
|
||||
addNode: vi.fn(),
|
||||
scanEventTs: 0,
|
||||
...overrides,
|
||||
@@ -70,9 +67,12 @@ function mockAuth() {
|
||||
const defaultProps = {
|
||||
onAddNode: vi.fn(),
|
||||
onAddGroupRect: vi.fn(),
|
||||
onAddText: vi.fn(),
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenHistory: vi.fn(),
|
||||
onOpenPending: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -190,16 +190,19 @@ describe('Sidebar', () => {
|
||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls toggleHideIp when Hide IPs is clicked', () => {
|
||||
// Regression (#186): the click handler must not forward the MouseEvent as an
|
||||
// argument — handleSave treats its first arg as a designIdOverride, so leaking
|
||||
// the event corrupts design_id and the save silently fails.
|
||||
it('calls onSave with no arguments (does not leak the click event)', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Hide IPs'))
|
||||
expect(mockToggleHideIp).toHaveBeenCalledOnce()
|
||||
fireEvent.click(screen.getByText('Save Canvas'))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||
})
|
||||
|
||||
it('shows Show IPs label when hideIp is true', () => {
|
||||
mockStore({ hideIp: true })
|
||||
it('calls onOpenSettings when Settings is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
expect(screen.getByText('Show IPs')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Unsaved changes badge ──────────────────────────────────────────────────
|
||||
@@ -242,29 +245,16 @@ describe('Sidebar', () => {
|
||||
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||
})
|
||||
|
||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||
it('calls onOpenHistory when Scan History nav item is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Scan History'))
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
expect(defaultProps.onOpenHistory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Regression: forceView must not freeze local state across rerenders.
|
||||
it('allows switching views after forceView is set by parent', async () => {
|
||||
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||
fireEvent.click(screen.getByText('Canvas'))
|
||||
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('toggles Settings panel on Settings click', async () => {
|
||||
it('calls onOpenSettings when Settings is clicked', () => {
|
||||
render(<Sidebar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Settings'))
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||
expect(defaultProps.onOpenSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// ── Logout ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Toolbar } from '../Toolbar'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
|
||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('@/stores/canvasStore')
|
||||
|
||||
vi.mock('@/components/ui/Logo', () => ({
|
||||
Logo: () => <div data-testid="logo" />,
|
||||
}))
|
||||
|
||||
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
hasUnsavedChanges: false,
|
||||
past: [],
|
||||
future: [],
|
||||
...overrides,
|
||||
} as ReturnType<typeof useCanvasStore>)
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
onSave: vi.fn(),
|
||||
onAutoLayout: vi.fn(),
|
||||
onExport: vi.fn(),
|
||||
onChangeStyle: vi.fn(),
|
||||
onUndo: vi.fn(),
|
||||
onRedo: vi.fn(),
|
||||
onShortcuts: vi.fn(),
|
||||
onExportMd: vi.fn(),
|
||||
onExportYaml: vi.fn(),
|
||||
onImportYaml: vi.fn(),
|
||||
onViewOnly: vi.fn(),
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Toolbar', () => {
|
||||
beforeEach(() => {
|
||||
mockStore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('calls onSave when Save is clicked', () => {
|
||||
render(<Toolbar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// Regression (#186): the click handler must not forward the MouseEvent as an
|
||||
// argument — handleSave treats its first arg as a designIdOverride, so leaking
|
||||
// the event corrupts design_id and the save silently fails.
|
||||
it('calls onSave with no arguments (does not leak the click event)', () => {
|
||||
render(<Toolbar {...defaultProps} />)
|
||||
fireEvent.click(screen.getByText('Save'))
|
||||
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -9,6 +9,7 @@ vi.mock('@/stores/authStore')
|
||||
|
||||
const mockUpdateNode = vi.fn()
|
||||
const mockNotifyScanDeviceFound = vi.fn()
|
||||
const mockSetServiceStatuses = vi.fn()
|
||||
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = []
|
||||
@@ -33,6 +34,7 @@ describe('useStatusPolling', () => {
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
updateNode: mockUpdateNode,
|
||||
notifyScanDeviceFound: mockNotifyScanDeviceFound,
|
||||
setServiceStatuses: mockSetServiceStatuses,
|
||||
} as ReturnType<typeof useCanvasStore>)
|
||||
|
||||
vi.mocked(useAuthStore).mockReturnValue({
|
||||
@@ -50,6 +52,7 @@ describe('useStatusPolling', () => {
|
||||
vi.restoreAllMocks()
|
||||
mockUpdateNode.mockClear()
|
||||
mockNotifyScanDeviceFound.mockClear()
|
||||
mockSetServiceStatuses.mockClear()
|
||||
})
|
||||
|
||||
it('does not open WebSocket when not authenticated', () => {
|
||||
@@ -147,6 +150,17 @@ describe('useStatusPolling', () => {
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes service_status messages to setServiceStatuses', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
|
||||
})
|
||||
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
|
||||
expect(mockUpdateNode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores malformed JSON without throwing', () => {
|
||||
renderHook(() => useStatusPolling())
|
||||
const ws = MockWebSocket.instances[0]
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { ServiceStatus } from '@/types'
|
||||
|
||||
interface ServiceStatusEntry {
|
||||
port?: number
|
||||
protocol?: string
|
||||
status: ServiceStatus
|
||||
}
|
||||
|
||||
interface StatusMessage {
|
||||
type?: string
|
||||
@@ -10,13 +17,14 @@ interface StatusMessage {
|
||||
response_time_ms?: number | null
|
||||
run_id?: string
|
||||
devices_found?: number
|
||||
services?: ServiceStatusEntry[]
|
||||
}
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export function useStatusPolling() {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const { updateNode, notifyScanDeviceFound } = useCanvasStore()
|
||||
const { updateNode, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
|
||||
const { isAuthenticated, token } = useAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,6 +47,8 @@ export function useStatusPolling() {
|
||||
const msg: StatusMessage = JSON.parse(event.data)
|
||||
if (msg.type === 'scan_device_found') {
|
||||
notifyScanDeviceFound()
|
||||
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
|
||||
setServiceStatuses(msg.node_id, msg.services)
|
||||
} else if (msg.node_id && msg.status) {
|
||||
updateNode(msg.node_id, {
|
||||
status: msg.status,
|
||||
@@ -59,5 +69,5 @@ export function useStatusPolling() {
|
||||
ws.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound])
|
||||
}, [isAuthenticated, token, updateNode, notifyScanDeviceFound, setServiceStatuses])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
/* Edge flow animations — CSS (not SVG SMIL) so the browser pauses them when the
|
||||
tab is hidden. SMIL <animate> keeps running in background tabs and leaks
|
||||
memory in Chrome over time; CSS animations do neither. */
|
||||
@keyframes homelable-snake {
|
||||
from { stroke-dashoffset: -10000; }
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
@keyframes homelable-flow {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: 18; }
|
||||
}
|
||||
|
||||
/* Disable React Flow's built-in edgeupdater entirely — HomelableEdge renders
|
||||
its own interactive endpoint dots in EdgeLabelRenderer (above the node
|
||||
layer) so the node Handle DOM cannot steal the reconnection drag. */
|
||||
|
||||
@@ -30,10 +30,37 @@ describe('canvasStore', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
serviceStatuses: {},
|
||||
})
|
||||
})
|
||||
|
||||
it('setServiceStatuses stores live status keyed by node/port/protocol', () => {
|
||||
const { setServiceStatuses } = useCanvasStore.getState()
|
||||
setServiceStatuses('node-1', [
|
||||
{ port: 80, protocol: 'tcp', status: 'offline' },
|
||||
{ port: 443, protocol: 'tcp', status: 'online' },
|
||||
])
|
||||
const { serviceStatuses } = useCanvasStore.getState()
|
||||
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
|
||||
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
|
||||
})
|
||||
|
||||
it('setServiceStatuses merges without dropping other nodes', () => {
|
||||
const { setServiceStatuses } = useCanvasStore.getState()
|
||||
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
|
||||
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
|
||||
const { serviceStatuses } = useCanvasStore.getState()
|
||||
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
|
||||
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
|
||||
})
|
||||
|
||||
it('does not mark canvas unsaved on a service status update', () => {
|
||||
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().setServiceStatuses('n', [{ port: 80, protocol: 'tcp', status: 'offline' }])
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('setEditingTextId sets and clears editing text id', () => {
|
||||
const { setEditingTextId } = useCanvasStore.getState()
|
||||
setEditingTextId('t1')
|
||||
@@ -123,6 +150,23 @@ describe('canvasStore', () => {
|
||||
expect(nested?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
it('addNode strips parentId/extent when the parent is not a container', () => {
|
||||
// Regression: a stray extent:'parent' on a non-container parent traps the
|
||||
// node in the parent's tiny box with no way to drag it out (issue #205).
|
||||
const parent = { ...makeNode('p1', { container_mode: false }), position: { x: 100, y: 100 } }
|
||||
useCanvasStore.getState().addNode(parent)
|
||||
const trapped: Node<NodeData> = {
|
||||
...makeNode('c1', { parent_id: 'p1' }),
|
||||
position: { x: 150, y: 180 },
|
||||
parentId: 'p1',
|
||||
extent: 'parent',
|
||||
}
|
||||
useCanvasStore.getState().addNode(trapped)
|
||||
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'c1')
|
||||
expect(child?.parentId).toBeUndefined()
|
||||
expect(child?.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('docker_container nests under docker_host with container_mode on', () => {
|
||||
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 100, y: 100 } }
|
||||
const container = { ...makeNode('dc1', { type: 'docker_container' }), position: { x: 160, y: 180 } }
|
||||
@@ -487,6 +531,186 @@ describe('canvasStore', () => {
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
// ── addToGroup ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('addToGroup nests a top-level node with parent-relative position', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(moved?.parentId).toBe('g1')
|
||||
expect(moved?.extent).toBe('parent')
|
||||
expect(moved?.data.parent_id).toBe('g1')
|
||||
// 300-76=224, 200-52=148
|
||||
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||
})
|
||||
|
||||
it('addToGroup places the group before the child in the array', () => {
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
// child first to prove reordering
|
||||
useCanvasStore.setState({ nodes: [child, group] })
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes.findIndex((n) => n.id === 'g1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||
})
|
||||
|
||||
it('addToGroup is a no-op when target is not a group', () => {
|
||||
const notGroup = { ...makeNode('s1'), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [notGroup, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('s1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToGroup is a no-op when child already belongs to the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToGroup snapshots history and marks unsaved', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
// ── addToContainer ──────────────────────────────────────────────────────────
|
||||
|
||||
it('addToContainer nests a top-level node under a container_mode node', () => {
|
||||
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true, label: 'PX' }), position: { x: 76, y: 52 }, width: 448, height: 252 }
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
useCanvasStore.setState({ nodes: [container, child] })
|
||||
|
||||
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||
|
||||
const moved = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')
|
||||
expect(moved?.parentId).toBe('px1')
|
||||
expect(moved?.extent).toBe('parent')
|
||||
expect(moved?.data.parent_id).toBe('px1')
|
||||
// 300-76=224, 200-52=148
|
||||
expect(moved?.position).toEqual({ x: 224, y: 148 })
|
||||
})
|
||||
|
||||
it('addToContainer works for any container_mode type (docker_host)', () => {
|
||||
const host = { ...makeNode('dh1', { type: 'docker_host', container_mode: true }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [host, child] })
|
||||
|
||||
useCanvasStore.getState().addToContainer('dh1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('dh1')
|
||||
})
|
||||
|
||||
it('addToContainer places the container before the child in the array', () => {
|
||||
const child = { ...makeNode('n1'), position: { x: 300, y: 200 } }
|
||||
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||
useCanvasStore.setState({ nodes: [child, container] })
|
||||
|
||||
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes.findIndex((n) => n.id === 'px1')).toBeLessThan(nodes.findIndex((n) => n.id === 'n1'))
|
||||
})
|
||||
|
||||
it('addToContainer is a no-op when target is not in container_mode', () => {
|
||||
const notContainer = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [notContainer, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToContainer is a no-op when child already belongs to the container', () => {
|
||||
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'px1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [container, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('addToContainer snapshots history and marks unsaved', () => {
|
||||
const container = { ...makeNode('px1', { type: 'proxmox', container_mode: true }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [container, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().addToContainer('px1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
// ── removeFromGroup ─────────────────────────────────────────────────────────
|
||||
|
||||
it('removeFromGroup releases the child to absolute coords and keeps the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 76, y: 52 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 224, y: 148 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
const released = nodes.find((n) => n.id === 'n1')
|
||||
expect(released?.parentId).toBeUndefined()
|
||||
expect(released?.extent).toBeUndefined()
|
||||
expect(released?.data.parent_id).toBeUndefined()
|
||||
// 224+76=300, 148+52=200
|
||||
expect(released?.position).toEqual({ x: 300, y: 200 })
|
||||
// group survives
|
||||
expect(nodes.find((n) => n.id === 'g1')).toBeDefined()
|
||||
})
|
||||
|
||||
it('removeFromGroup is a no-op when child is not in the group', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 } }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
|
||||
})
|
||||
|
||||
it('removeFromGroup snapshots history and marks unsaved', () => {
|
||||
const group = { ...makeNode('g1', { type: 'group', label: 'G' }), position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('n1'), position: { x: 50, y: 50 }, parentId: 'g1', extent: 'parent' as const }
|
||||
useCanvasStore.setState({ nodes: [group, child] })
|
||||
useCanvasStore.getState().markSaved()
|
||||
|
||||
useCanvasStore.getState().removeFromGroup('g1', 'n1')
|
||||
|
||||
expect(useCanvasStore.getState().past).toHaveLength(1)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('updateEdge updates edge data and marks unsaved', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
@@ -690,29 +914,136 @@ describe('canvasStore', () => {
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
const { clipboard } = useCanvasStore.getState()
|
||||
expect(clipboard).toHaveLength(1)
|
||||
expect(clipboard[0].id).toBe('a')
|
||||
expect(clipboard.nodes).toHaveLength(1)
|
||||
expect(clipboard.nodes[0].id).toBe('a')
|
||||
})
|
||||
|
||||
it('pasteNodes creates new nodes with new IDs and offset position', () => {
|
||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 }, selected: true }
|
||||
useCanvasStore.setState({ nodes: [node], edges: [], clipboard: [node] })
|
||||
it('copySelectedNodes captures edges whose endpoints are both selected', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{ ...makeNode('a'), selected: true },
|
||||
{ ...makeNode('b'), selected: true },
|
||||
{ ...makeNode('c'), selected: false },
|
||||
],
|
||||
edges: [makeEdge('e-ab', 'a', 'b'), makeEdge('e-bc', 'b', 'c')],
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
const { clipboard } = useCanvasStore.getState()
|
||||
expect(clipboard.nodes.map((n) => n.id).sort()).toEqual(['a', 'b'])
|
||||
expect(clipboard.edges).toHaveLength(1)
|
||||
expect(clipboard.edges[0].id).toBe('e-ab')
|
||||
})
|
||||
|
||||
it('copySelectedNodes pulls in children of a selected group', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{ ...makeNode('g', { type: 'group' }), type: 'group', selected: true },
|
||||
{ ...makeNode('child', { parent_id: 'g' }), parentId: 'g', selected: false },
|
||||
],
|
||||
edges: [],
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
expect(useCanvasStore.getState().clipboard.nodes.map((n) => n.id).sort()).toEqual(['child', 'g'])
|
||||
})
|
||||
|
||||
it('pasteNodes creates new nodes with new IDs and a cascade offset by default', () => {
|
||||
const node = { ...makeNode('src'), position: { x: 100, y: 100 } }
|
||||
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
expect(nodes).toHaveLength(2)
|
||||
const pasted = nodes.find((n) => n.id !== 'src')!
|
||||
expect(pasted).toBeDefined()
|
||||
expect(pasted.position.x).toBe(150)
|
||||
expect(pasted.position.y).toBe(150)
|
||||
expect(pasted.selected).toBe(false)
|
||||
expect(nodes).toHaveLength(1)
|
||||
const pasted = nodes[0]
|
||||
expect(pasted.id).not.toBe('src')
|
||||
expect(pasted.position).toEqual({ x: 150, y: 150 })
|
||||
expect(pasted.selected).toBe(true)
|
||||
})
|
||||
|
||||
it('pasteNodes centers the pasted bounding box on the target point', () => {
|
||||
const node = { ...makeNode('src'), position: { x: 0, y: 0 }, width: 100, height: 100 }
|
||||
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [node], edges: [] } })
|
||||
useCanvasStore.getState().pasteNodes({ x: 500, y: 300 })
|
||||
const pasted = useCanvasStore.getState().nodes[0]
|
||||
// bbox center (50,50) shifted onto (500,300) → top-left at (450,250)
|
||||
expect(pasted.position).toEqual({ x: 450, y: 250 })
|
||||
})
|
||||
|
||||
it('pasteNodes remaps edge endpoints to the new node IDs', () => {
|
||||
const a = { ...makeNode('a') }
|
||||
const b = { ...makeNode('b') }
|
||||
useCanvasStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
clipboard: { nodes: [a, b], edges: [makeEdge('e-ab', 'a', 'b')] },
|
||||
})
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const { nodes, edges } = useCanvasStore.getState()
|
||||
expect(edges).toHaveLength(1)
|
||||
const ids = nodes.map((n) => n.id)
|
||||
expect(ids).toContain(edges[0].source)
|
||||
expect(ids).toContain(edges[0].target)
|
||||
expect(edges[0].source).not.toBe('a')
|
||||
expect(edges[0].id).not.toBe('e-ab')
|
||||
})
|
||||
|
||||
it('pasteNodes preserves parent-child relationship under remapped IDs', () => {
|
||||
const group = { ...makeNode('g', { type: 'group' }), type: 'group', position: { x: 0, y: 0 } }
|
||||
const child = { ...makeNode('child', { parent_id: 'g' }), parentId: 'g', extent: 'parent' as const, position: { x: 20, y: 30 } }
|
||||
useCanvasStore.setState({ nodes: [], edges: [], clipboard: { nodes: [group, child], edges: [] } })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const { nodes } = useCanvasStore.getState()
|
||||
const newGroup = nodes.find((n) => n.data.type === 'group')!
|
||||
const newChild = nodes.find((n) => n.id !== newGroup.id)!
|
||||
expect(newChild.parentId).toBe(newGroup.id)
|
||||
expect(newChild.data.parent_id).toBe(newGroup.id)
|
||||
// Child keeps its parent-relative position (no offset applied to children)
|
||||
expect(newChild.position).toEqual({ x: 20, y: 30 })
|
||||
// Group (the root) precedes its child in the array
|
||||
expect(nodes.findIndex((n) => n.id === newGroup.id)).toBeLessThan(
|
||||
nodes.findIndex((n) => n.id === newChild.id),
|
||||
)
|
||||
})
|
||||
|
||||
it('clipboard survives loadCanvas so nodes can be pasted into another design', () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [{ ...makeNode('a'), selected: true }],
|
||||
edges: [],
|
||||
})
|
||||
useCanvasStore.getState().copySelectedNodes()
|
||||
// Switch to another design: loadCanvas replaces nodes/edges.
|
||||
useCanvasStore.getState().loadCanvas([makeNode('other')], [])
|
||||
expect(useCanvasStore.getState().clipboard.nodes).toHaveLength(1)
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
|
||||
expect(ids).toContain('other')
|
||||
expect(ids).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('pasteNodes does nothing when clipboard is empty', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: [] })
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], edges: [], clipboard: { nodes: [], edges: [] } })
|
||||
useCanvasStore.getState().pasteNodes()
|
||||
expect(useCanvasStore.getState().nodes).toHaveLength(1)
|
||||
})
|
||||
|
||||
// --- Hide IP preference (persisted to localStorage) ---
|
||||
|
||||
it('toggleHideIp flips the flag and persists it', () => {
|
||||
localStorage.removeItem('homelable.hideIp')
|
||||
useCanvasStore.setState({ hideIp: false })
|
||||
useCanvasStore.getState().toggleHideIp()
|
||||
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||
useCanvasStore.getState().toggleHideIp()
|
||||
expect(useCanvasStore.getState().hideIp).toBe(false)
|
||||
expect(localStorage.getItem('homelable.hideIp')).toBe('false')
|
||||
})
|
||||
|
||||
it('setHideIp sets the flag and persists it', () => {
|
||||
localStorage.removeItem('homelable.hideIp')
|
||||
useCanvasStore.getState().setHideIp(true)
|
||||
expect(useCanvasStore.getState().hideIp).toBe(true)
|
||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||
})
|
||||
|
||||
// --- Node resizing (width / height) ---
|
||||
|
||||
it('addNode preserves explicit width and height', () => {
|
||||
@@ -867,7 +1198,7 @@ describe('canvasStore — custom style apply', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -949,4 +1280,29 @@ describe('canvasStore — custom style apply', () => {
|
||||
expect(e.data?.custom_color).toBe('#aabbcc')
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('setNodeSize sets explicit width/height and marks unsaved', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], hasUnsavedChanges: false })
|
||||
useCanvasStore.getState().setNodeSize('n1', { width: 220, height: 130 })
|
||||
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||
expect(n.width).toBe(220)
|
||||
expect(n.height).toBe(130)
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('setNodeSize clamps below the minimum box', () => {
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')] })
|
||||
useCanvasStore.getState().setNodeSize('n1', { width: 10, height: 10 })
|
||||
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||
expect(n.width).toBe(140)
|
||||
expect(n.height).toBe(50)
|
||||
})
|
||||
|
||||
it('setNodeSize updates only the provided axis', () => {
|
||||
useCanvasStore.setState({ nodes: [{ ...makeNode('n1'), width: 200, height: 100 }] })
|
||||
useCanvasStore.getState().setNodeSize('n1', { width: 300 })
|
||||
const n = useCanvasStore.getState().nodes.find((x) => x.id === 'n1')!
|
||||
expect(n.width).toBe(300)
|
||||
expect(n.height).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import type { Design } from '@/types'
|
||||
|
||||
function design(id: string, type: Design['design_type'] = 'network', name = id): Design {
|
||||
return { id, name, design_type: type, created_at: '', updated_at: '' }
|
||||
}
|
||||
|
||||
describe('designStore', () => {
|
||||
beforeEach(() => {
|
||||
useDesignStore.setState({ designs: [], activeDesignId: null, activeDesignType: null, loaded: false })
|
||||
})
|
||||
|
||||
it('starts empty and not loaded', () => {
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toEqual([])
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
expect(s.loaded).toBe(false)
|
||||
})
|
||||
|
||||
it('setDesigns selects the first design as active and marks loaded', () => {
|
||||
const a = design('a', 'network')
|
||||
const b = design('b', 'electrical')
|
||||
useDesignStore.getState().setDesigns([a, b])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toHaveLength(2)
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
expect(s.activeDesignType).toBe('network')
|
||||
expect(s.loaded).toBe(true)
|
||||
})
|
||||
|
||||
it('setDesigns preserves the active design when it is still present', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
// Re-list (e.g. after creating another design) — active id must not jump back to first.
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical'), design('c')])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('setDesigns falls back to first when the active design was removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
useDesignStore.getState().setDesigns([design('a')]) // 'b' deleted
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
expect(s.activeDesignType).toBe('network')
|
||||
})
|
||||
|
||||
it('setDesigns with an empty list clears the active selection', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setDesigns([])
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
expect(s.loaded).toBe(true)
|
||||
})
|
||||
|
||||
it('setActiveDesign updates id and resolves type', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('setActiveDesign with an unknown id sets a null type', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setActiveDesign('missing')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.activeDesignId).toBe('missing')
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
})
|
||||
|
||||
it('getActiveDesign returns the active design or null', () => {
|
||||
expect(useDesignStore.getState().getActiveDesign()).toBeNull()
|
||||
const b = design('b', 'electrical')
|
||||
useDesignStore.getState().setDesigns([design('a'), b])
|
||||
useDesignStore.getState().setActiveDesign('b')
|
||||
expect(useDesignStore.getState().getActiveDesign()).toEqual(b)
|
||||
})
|
||||
|
||||
it('addDesign appends and makes the new design active', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
const b = design('b', 'electrical', 'Power')
|
||||
useDesignStore.getState().addDesign(b)
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['a', 'b'])
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('updateDesign patches name and icon in place without touching others', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b')])
|
||||
useDesignStore.getState().updateDesign('a', { name: 'Renamed', icon: 'server' })
|
||||
const designs = useDesignStore.getState().designs
|
||||
expect(designs.find((d) => d.id === 'a')).toMatchObject({ name: 'Renamed', icon: 'server' })
|
||||
expect(designs.find((d) => d.id === 'b')!.name).toBe('b')
|
||||
})
|
||||
|
||||
it('removeDesign drops a non-active design and keeps the active one', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('b')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['a'])
|
||||
expect(s.activeDesignId).toBe('a')
|
||||
})
|
||||
|
||||
it('removeDesign reassigns active to the first remaining when the active is removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a'), design('b', 'electrical')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('a')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs.map((d) => d.id)).toEqual(['b'])
|
||||
expect(s.activeDesignId).toBe('b')
|
||||
expect(s.activeDesignType).toBe('electrical')
|
||||
})
|
||||
|
||||
it('removeDesign clears active when the last design is removed', () => {
|
||||
useDesignStore.getState().setDesigns([design('a')])
|
||||
useDesignStore.getState().setActiveDesign('a')
|
||||
useDesignStore.getState().removeDesign('a')
|
||||
const s = useDesignStore.getState()
|
||||
expect(s.designs).toEqual([])
|
||||
expect(s.activeDesignId).toBeNull()
|
||||
expect(s.activeDesignType).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -9,12 +9,21 @@ import {
|
||||
applyEdgeChanges,
|
||||
addEdge,
|
||||
} from '@xyflow/react'
|
||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef } from '@/types'
|
||||
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||
|
||||
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
|
||||
|
||||
/** Resolve a node's effective parent id from either the RF field or domain data. */
|
||||
const parentIdOf = (n: Node<NodeData>): string | undefined => n.parentId ?? n.data.parent_id ?? undefined
|
||||
|
||||
/** Key for the live per-service status overlay. */
|
||||
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
|
||||
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
|
||||
|
||||
interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
@@ -23,6 +32,8 @@ interface CanvasState {
|
||||
selectedNodeId: string | null
|
||||
selectedNodeIds: string[]
|
||||
scanEventTs: number
|
||||
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
|
||||
serviceStatuses: Record<string, ServiceStatus>
|
||||
|
||||
// History
|
||||
past: HistoryEntry[]
|
||||
@@ -31,10 +42,12 @@ interface CanvasState {
|
||||
undo: () => void
|
||||
redo: () => void
|
||||
|
||||
// Clipboard
|
||||
clipboard: Node<NodeData>[]
|
||||
// Clipboard — survives design switches so nodes can be pasted into another design
|
||||
clipboard: Clipboard
|
||||
copySelectedNodes: () => void
|
||||
pasteNodes: () => void
|
||||
/** Paste clipboard into the current canvas. `center` (flow coords) lands the
|
||||
* pasted bounding-box center under the cursor / viewport center. */
|
||||
pasteNodes: (center?: { x: number; y: number }) => void
|
||||
|
||||
onNodesChange: (changes: NodeChange<Node<NodeData>>[]) => void
|
||||
onEdgesChange: (changes: EdgeChange<Edge<EdgeData>>[]) => void
|
||||
@@ -48,6 +61,7 @@ interface CanvasState {
|
||||
deleteEdge: (id: string) => void
|
||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||
setNodeZIndex: (id: string, zIndex: number) => void
|
||||
setNodeSize: (id: string, size: { width?: number; height?: number }) => void
|
||||
editingGroupRectId: string | null
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
editingTextId: string | null
|
||||
@@ -55,14 +69,19 @@ interface CanvasState {
|
||||
toggleNodeCollapsed: (id: string) => void
|
||||
createGroup: (nodeIds: string[], name: string) => void
|
||||
ungroup: (groupId: string) => void
|
||||
addToGroup: (groupId: string, childId: string) => void
|
||||
addToContainer: (containerId: string, childId: string) => void
|
||||
removeFromGroup: (groupId: string, childId: string) => void
|
||||
markSaved: () => void
|
||||
markUnsaved: () => void
|
||||
loadCanvas: (nodes: Node<NodeData>[], edges: Edge<EdgeData>[]) => void
|
||||
fitViewPending: boolean
|
||||
clearFitViewPending: () => void
|
||||
notifyScanDeviceFound: () => void
|
||||
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
|
||||
hideIp: boolean
|
||||
toggleHideIp: () => void
|
||||
setHideIp: (value: boolean) => void
|
||||
applyTypeNodeStyle: (nodeType: NodeType, style: NodeTypeStyle) => void
|
||||
applyTypeEdgeStyle: (edgeType: EdgeType, style: EdgeTypeStyle) => void
|
||||
applyAllCustomStyles: (def: CustomStyleDef) => void
|
||||
@@ -76,13 +95,14 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
editingTextId: null,
|
||||
hideIp: false,
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
serviceStatuses: {},
|
||||
fitViewPending: false,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
|
||||
snapshotHistory: () =>
|
||||
set((state) => ({
|
||||
@@ -117,24 +137,100 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}),
|
||||
|
||||
copySelectedNodes: () =>
|
||||
set((state) => ({
|
||||
clipboard: state.nodes.filter((n) => n.selected),
|
||||
})),
|
||||
|
||||
pasteNodes: () =>
|
||||
set((state) => {
|
||||
if (state.clipboard.length === 0) return state
|
||||
const newNodes = state.clipboard.map((n) => ({
|
||||
...n,
|
||||
// Start from explicitly selected nodes, then pull in all descendants so a
|
||||
// copied group / container brings its children along.
|
||||
const ids = new Set(state.nodes.filter((n) => n.selected).map((n) => n.id))
|
||||
if (ids.size === 0) return { clipboard: { nodes: [], edges: [] } }
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for (const n of state.nodes) {
|
||||
const pid = parentIdOf(n)
|
||||
if (pid && ids.has(pid) && !ids.has(n.id)) {
|
||||
ids.add(n.id)
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const nodes = state.nodes.filter((n) => ids.has(n.id))
|
||||
// Keep only edges whose both endpoints are inside the copied set.
|
||||
const edges = state.edges.filter((e) => ids.has(e.source) && ids.has(e.target))
|
||||
return { clipboard: { nodes, edges } }
|
||||
}),
|
||||
|
||||
pasteNodes: (center) =>
|
||||
set((state) => {
|
||||
const clip = state.clipboard
|
||||
if (clip.nodes.length === 0) return state
|
||||
|
||||
// Fresh ids for every copied node; edges/parent links are remapped through it.
|
||||
const idMap = new Map<string, string>()
|
||||
clip.nodes.forEach((n) => idMap.set(n.id, generateUUID()))
|
||||
|
||||
// A "root" is a copied node whose parent was not also copied — these carry
|
||||
// absolute positions and receive the paste offset; children move with them.
|
||||
const isRoot = (n: Node<NodeData>) => {
|
||||
const pid = parentIdOf(n)
|
||||
return !pid || !idMap.has(pid)
|
||||
}
|
||||
const roots = clip.nodes.filter(isRoot)
|
||||
|
||||
// Default cascade offset; when a target center is given, shift the root
|
||||
// bounding-box center onto it instead.
|
||||
let offsetX = 50
|
||||
let offsetY = 50
|
||||
if (center && roots.length > 0) {
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
|
||||
for (const n of roots) {
|
||||
const w = n.width ?? n.measured?.width ?? 200
|
||||
const h = n.height ?? n.measured?.height ?? 80
|
||||
minX = Math.min(minX, n.position.x)
|
||||
minY = Math.min(minY, n.position.y)
|
||||
maxX = Math.max(maxX, n.position.x + w)
|
||||
maxY = Math.max(maxY, n.position.y + h)
|
||||
}
|
||||
offsetX = center.x - (minX + maxX) / 2
|
||||
offsetY = center.y - (minY + maxY) / 2
|
||||
}
|
||||
|
||||
const pasted = clip.nodes.map((n) => {
|
||||
const root = isRoot(n)
|
||||
const newParentId = root ? undefined : idMap.get(parentIdOf(n)!)
|
||||
return {
|
||||
...n,
|
||||
id: idMap.get(n.id)!,
|
||||
position: root
|
||||
? { x: n.position.x + offsetX, y: n.position.y + offsetY }
|
||||
: { ...n.position },
|
||||
selected: true,
|
||||
parentId: newParentId,
|
||||
extent: newParentId ? ('parent' as const) : undefined,
|
||||
data: { ...n.data, parent_id: newParentId },
|
||||
}
|
||||
})
|
||||
|
||||
const pastedEdges = clip.edges.map((e) => ({
|
||||
...e,
|
||||
id: generateUUID(),
|
||||
position: { x: n.position.x + 50, y: n.position.y + 50 },
|
||||
source: idMap.get(e.source)!,
|
||||
target: idMap.get(e.target)!,
|
||||
selected: false,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
data: { ...n.data, parent_id: undefined },
|
||||
}))
|
||||
|
||||
// React Flow requires parents before children within the appended block.
|
||||
const parents = pasted.filter((n) => !n.parentId)
|
||||
const children = pasted.filter((n) => !!n.parentId)
|
||||
const pastedNodes = [...parents, ...children]
|
||||
|
||||
// Deselect everything already on the canvas so only the paste is selected.
|
||||
const existing = state.nodes.map((n) => (n.selected ? { ...n, selected: false } : n))
|
||||
|
||||
return {
|
||||
nodes: [...state.nodes, ...newNodes],
|
||||
nodes: [...existing, ...pastedNodes],
|
||||
edges: [...state.edges, ...pastedEdges],
|
||||
selectedNodeId: null,
|
||||
selectedNodeIds: pastedNodes.map((n) => n.id),
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
hasUnsavedChanges: true,
|
||||
@@ -193,7 +289,9 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
y: Math.max(10, node.position.y - parent.position.y),
|
||||
},
|
||||
}
|
||||
: node
|
||||
// Not nesting: strip any parentId/extent a caller may have set so a
|
||||
// non-container parent can't trap the node in its bounding box.
|
||||
: { ...node, parentId: undefined, extent: undefined }
|
||||
// Parents must come before children in the array (React Flow requirement)
|
||||
const withoutNew = state.nodes.filter((n) => n.id !== node.id)
|
||||
if (enriched.parentId) {
|
||||
@@ -371,6 +469,22 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
// Manual width/height entry. Lets the user type an exact size instead of
|
||||
// dragging the resize handle (which lands on fractional content-fit pixels).
|
||||
// A clamp matches the NodeResizer minimums so the box can't collapse.
|
||||
setNodeSize: (id, size) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) => {
|
||||
if (n.id !== id) return n
|
||||
return {
|
||||
...n,
|
||||
...(size.width != null ? { width: Math.max(140, size.width) } : {}),
|
||||
...(size.height != null ? { height: Math.max(50, size.height) } : {}),
|
||||
}
|
||||
}),
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
setEditingGroupRectId: (id) => set({ editingGroupRectId: id }),
|
||||
|
||||
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||
@@ -491,19 +605,155 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
}
|
||||
}),
|
||||
|
||||
// Nest an existing top-level node inside a group. Inverse of removeFromGroup.
|
||||
addToGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
const group = state.nodes.find((n) => n.id === groupId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!group || !child || group.data.type !== 'group') return state
|
||||
if (child.id === groupId || child.parentId === groupId) return state
|
||||
|
||||
const updatedNodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: groupId,
|
||||
extent: 'parent' as const,
|
||||
// Absolute → group-relative. Clamp so the node stays inside the box.
|
||||
position: {
|
||||
x: Math.max(8, n.position.x - group.position.x),
|
||||
y: Math.max(8, n.position.y - group.position.y),
|
||||
},
|
||||
selected: false,
|
||||
data: { ...n.data, parent_id: groupId },
|
||||
}
|
||||
})
|
||||
|
||||
// React Flow requires the parent to precede its children in the array.
|
||||
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||
const groupIdx = others.findIndex((n) => n.id === groupId)
|
||||
const nodes = [
|
||||
...others.slice(0, groupIdx + 1),
|
||||
movedChild,
|
||||
...others.slice(groupIdx + 1),
|
||||
]
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
// Nest an existing top-level node inside a container node (proxmox /
|
||||
// docker_host / … in container_mode). Mirrors addToGroup but the target is
|
||||
// any node with data.container_mode === true rather than a group.
|
||||
addToContainer: (containerId, childId) =>
|
||||
set((state) => {
|
||||
const container = state.nodes.find((n) => n.id === containerId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!container || !child || container.data.container_mode !== true) return state
|
||||
if (child.id === containerId || child.parentId === containerId) return state
|
||||
|
||||
const updatedNodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: containerId,
|
||||
extent: 'parent' as const,
|
||||
// Absolute → container-relative. Clamp so the node stays inside.
|
||||
position: {
|
||||
x: Math.max(8, n.position.x - container.position.x),
|
||||
y: Math.max(8, n.position.y - container.position.y),
|
||||
},
|
||||
selected: false,
|
||||
data: { ...n.data, parent_id: containerId },
|
||||
}
|
||||
})
|
||||
|
||||
// React Flow requires the parent to precede its children in the array.
|
||||
const others = updatedNodes.filter((n) => n.id !== childId)
|
||||
const movedChild = updatedNodes.find((n) => n.id === childId)!
|
||||
const containerIdx = others.findIndex((n) => n.id === containerId)
|
||||
const nodes = [
|
||||
...others.slice(0, containerIdx + 1),
|
||||
movedChild,
|
||||
...others.slice(containerIdx + 1),
|
||||
]
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
// Release a single child from a group back to the canvas. Group stays.
|
||||
removeFromGroup: (groupId, childId) =>
|
||||
set((state) => {
|
||||
const group = state.nodes.find((n) => n.id === groupId)
|
||||
const child = state.nodes.find((n) => n.id === childId)
|
||||
if (!group || !child || child.parentId !== groupId) return state
|
||||
|
||||
const nodes = state.nodes.map((n) => {
|
||||
if (n.id !== childId) return n
|
||||
return {
|
||||
...n,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: n.position.x + group.position.x,
|
||||
y: n.position.y + group.position.y,
|
||||
},
|
||||
data: { ...n.data, parent_id: undefined },
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
nodes,
|
||||
hasUnsavedChanges: true,
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
}
|
||||
}),
|
||||
|
||||
markSaved: () => set({ hasUnsavedChanges: false }),
|
||||
|
||||
markUnsaved: () => set({ hasUnsavedChanges: true }),
|
||||
|
||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||
|
||||
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
|
||||
setServiceStatuses: (nodeId, statuses) =>
|
||||
set((state) => {
|
||||
// Live overlay only — never touches node data, so it stays out of saves.
|
||||
const next = { ...state.serviceStatuses }
|
||||
for (const s of statuses) {
|
||||
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
|
||||
}
|
||||
return { serviceStatuses: next }
|
||||
}),
|
||||
|
||||
toggleHideIp: () => set((s) => {
|
||||
const hideIp = !s.hideIp
|
||||
writeHideIp(hideIp)
|
||||
return { hideIp }
|
||||
}),
|
||||
|
||||
setHideIp: (value) => {
|
||||
writeHideIp(value)
|
||||
set({ hideIp: value })
|
||||
},
|
||||
|
||||
loadCanvas: (nodes, edges) => {
|
||||
// React Flow requires parents before children in the array
|
||||
const parents = nodes.filter((n) => !n.parentId)
|
||||
const children = nodes.filter((n) => !!n.parentId)
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], clipboard: [], fitViewPending: true })
|
||||
// NOTE: clipboard is intentionally preserved here so nodes copied in one
|
||||
// design can be pasted after switching to another design.
|
||||
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
|
||||
},
|
||||
|
||||
clearFitViewPending: () => set({ fitViewPending: false }),
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Design, DesignType } from '@/types'
|
||||
|
||||
interface DesignState {
|
||||
designs: Design[]
|
||||
activeDesignId: string | null
|
||||
activeDesignType: DesignType | null
|
||||
loaded: boolean
|
||||
setDesigns: (designs: Design[]) => void
|
||||
setActiveDesign: (id: string) => void
|
||||
getActiveDesign: () => Design | null
|
||||
/** Append a new design and make it active. */
|
||||
addDesign: (design: Design) => void
|
||||
/** Patch an existing design in place (name/icon edits). */
|
||||
updateDesign: (id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>) => void
|
||||
/** Remove a design; if it was active, fall back to the first remaining one. */
|
||||
removeDesign: (id: string) => void
|
||||
}
|
||||
|
||||
export const useDesignStore = create<DesignState>((set, get) => ({
|
||||
designs: [],
|
||||
activeDesignId: null,
|
||||
activeDesignType: null,
|
||||
loaded: false,
|
||||
|
||||
setDesigns: (designs) =>
|
||||
set((state) => {
|
||||
const nextId = state.activeDesignId && designs.find((d) => d.id === state.activeDesignId)
|
||||
? state.activeDesignId
|
||||
: designs[0]?.id ?? null
|
||||
const nextType = nextId ? designs.find((d) => d.id === nextId)?.design_type ?? null : null
|
||||
return { designs, activeDesignId: nextId, activeDesignType: nextType, loaded: true }
|
||||
}),
|
||||
|
||||
setActiveDesign: (id) =>
|
||||
set((state) => {
|
||||
const design = state.designs.find((d) => d.id === id)
|
||||
return {
|
||||
activeDesignId: id,
|
||||
activeDesignType: design?.design_type ?? null,
|
||||
}
|
||||
}),
|
||||
|
||||
getActiveDesign: () => {
|
||||
const { designs, activeDesignId } = get()
|
||||
return designs.find((d) => d.id === activeDesignId) ?? null
|
||||
},
|
||||
|
||||
addDesign: (design) =>
|
||||
set((state) => ({
|
||||
designs: [...state.designs, design],
|
||||
activeDesignId: design.id,
|
||||
activeDesignType: design.design_type,
|
||||
})),
|
||||
|
||||
updateDesign: (id, patch) =>
|
||||
set((state) => ({
|
||||
designs: state.designs.map((d) => (d.id === id ? { ...d, ...patch } : d)),
|
||||
})),
|
||||
|
||||
removeDesign: (id) =>
|
||||
set((state) => {
|
||||
const designs = state.designs.filter((d) => d.id !== id)
|
||||
if (state.activeDesignId !== id) return { designs }
|
||||
const next = designs[0] ?? null
|
||||
return {
|
||||
designs,
|
||||
activeDesignId: next?.id ?? null,
|
||||
activeDesignType: next?.design_type ?? null,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
@@ -1,3 +1,15 @@
|
||||
export type DesignType = 'network' | 'electrical'
|
||||
|
||||
export interface Design {
|
||||
id: string
|
||||
name: string
|
||||
design_type: DesignType
|
||||
/** Lucide icon key (see utils/designIcons). User-chosen; may be null on legacy rows. */
|
||||
icon?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type NodeType =
|
||||
| 'isp'
|
||||
| 'router'
|
||||
@@ -25,6 +37,20 @@ export type NodeType =
|
||||
| 'zigbee_coordinator'
|
||||
| 'zigbee_router'
|
||||
| 'zigbee_enddevice'
|
||||
| 'grid'
|
||||
| 'ups'
|
||||
| 'battery'
|
||||
| 'generator'
|
||||
| 'solar_panel'
|
||||
| 'inverter'
|
||||
| 'circuit_breaker'
|
||||
| 'contactor'
|
||||
| 'electrical_switch'
|
||||
| 'socket'
|
||||
| 'light'
|
||||
| 'meter'
|
||||
| 'transformer'
|
||||
| 'load'
|
||||
|
||||
export type TextPosition =
|
||||
| 'top-left'
|
||||
@@ -37,7 +63,7 @@ export type TextPosition =
|
||||
| 'bottom-center'
|
||||
| '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'
|
||||
|
||||
@@ -52,6 +78,8 @@ export interface ServiceInfo {
|
||||
category?: string
|
||||
}
|
||||
|
||||
export type ServiceStatus = 'online' | 'offline' | 'unknown'
|
||||
|
||||
export interface NodeProperty {
|
||||
key: string
|
||||
value: string
|
||||
@@ -159,6 +187,20 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
zigbee_coordinator: 'Zigbee Coordinator',
|
||||
zigbee_router: 'Zigbee Router',
|
||||
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> = {
|
||||
@@ -176,6 +218,7 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
||||
virtual: 'Virtual',
|
||||
cluster: 'Cluster',
|
||||
fibre: 'Fibre',
|
||||
electrical: 'Electrical Wire',
|
||||
}
|
||||
|
||||
export interface NodeTypeStyle {
|
||||
|
||||
@@ -102,6 +102,20 @@ describe('serializeNode — regular node', () => {
|
||||
expect(result.height).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers explicit width/height over the measured (content-fit) value', () => {
|
||||
const node: Node<NodeData> = { ...makeRfNode({ width: 200, height: 100 }), measured: { width: 187, height: 73 } }
|
||||
const result = serializeNode(node)
|
||||
expect(result.width).toBe(200)
|
||||
expect(result.height).toBe(100)
|
||||
})
|
||||
|
||||
it('falls back to measured when no explicit dimension is set', () => {
|
||||
const node: Node<NodeData> = { ...makeRfNode(), measured: { width: 187, height: 73 } }
|
||||
const result = serializeNode(node)
|
||||
expect(result.width).toBe(187)
|
||||
expect(result.height).toBe(73)
|
||||
})
|
||||
|
||||
it('serializes hardware fields', () => {
|
||||
const node = makeRfNode({
|
||||
data: {
|
||||
@@ -162,13 +176,14 @@ describe('serializeNode — groupRect', () => {
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(250)
|
||||
})
|
||||
|
||||
it('falls back to measured dimensions over explicit width/height', () => {
|
||||
it('prefers explicit width/height over measured, falling back to measured per-axis', () => {
|
||||
const node: Node<NodeData> = {
|
||||
...makeRfNode({ type: 'groupRect', data: { label: 'Z', type: 'groupRect', status: 'unknown', services: [] }, width: 400 }),
|
||||
measured: { width: 420, height: 260 },
|
||||
}
|
||||
const result = serializeNode(node)
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(420)
|
||||
// Explicit width wins; height has no explicit value so it uses measured.
|
||||
expect((result.custom_colors as Record<string, unknown>).width).toBe(400)
|
||||
expect((result.custom_colors as Record<string, unknown>).height).toBe(260)
|
||||
})
|
||||
|
||||
@@ -323,6 +338,21 @@ describe('deserializeApiNode — regular node', () => {
|
||||
expect(result.width).toBeUndefined()
|
||||
expect(result.height).toBeUndefined()
|
||||
})
|
||||
|
||||
// Regression: issue #205 — a leaf vm/lxc nested in a container would lose its
|
||||
// saved size on reload because the restore branch excluded those types.
|
||||
it.each(['vm', 'lxc', 'docker_host'] as const)(
|
||||
'restores saved width/height for a leaf %s node (container_mode false)',
|
||||
(type) => {
|
||||
const map = new Map([['px1', true]])
|
||||
const result = deserializeApiNode(
|
||||
makeApiNode({ type, container_mode: false, parent_id: 'px1', width: 240, height: 90 }),
|
||||
map,
|
||||
)
|
||||
expect(result.width).toBe(240)
|
||||
expect(result.height).toBe(90)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
// ── deserializeApiNode — groupRect ────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
|
||||
|
||||
describe('designIcons', () => {
|
||||
it('exposes a non-empty, unique-keyed icon set', () => {
|
||||
expect(DESIGN_ICONS.length).toBeGreaterThan(0)
|
||||
const keys = DESIGN_ICONS.map((e) => e.key)
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
})
|
||||
|
||||
it('default icon key is part of the set', () => {
|
||||
expect(DESIGN_ICONS.some((e) => e.key === DEFAULT_DESIGN_ICON)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolveDesignIcon returns the matching component for a known key', () => {
|
||||
const entry = DESIGN_ICONS.find((e) => e.key === 'zap')!
|
||||
expect(resolveDesignIcon('zap')).toBe(entry.icon)
|
||||
})
|
||||
|
||||
it('resolveDesignIcon falls back to a component for unknown/empty keys', () => {
|
||||
const fallback = resolveDesignIcon(undefined)
|
||||
expect(typeof fallback).toBe('object')
|
||||
expect(resolveDesignIcon('does-not-exist')).toBe(fallback)
|
||||
expect(resolveDesignIcon(null)).toBe(fallback)
|
||||
expect(resolveDesignIcon('')).toBe(fallback)
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,33 @@ describe('generateMarkdownTable', () => {
|
||||
expect(md).toContain('A\\|B')
|
||||
})
|
||||
|
||||
it('escapes backslashes before pipes so the escape char is not ambiguous', () => {
|
||||
const nodes = [makeNode({ label: 'A\\|B' })]
|
||||
const md = generateMarkdownTable(nodes)
|
||||
// backslash doubled, then the literal pipe escaped
|
||||
expect(md).toContain('A\\\\\\|B')
|
||||
})
|
||||
|
||||
it('collapses newlines in cell values so they do not break the table', () => {
|
||||
const nodes = [makeNode({ label: 'line1\nline2', hostname: 'a\r\nb' })]
|
||||
const lines = generateMarkdownTable(nodes).split('\n')
|
||||
// header + separator + exactly one data row (no extra line from the value)
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[2]).toContain('line1 line2')
|
||||
expect(lines[2]).toContain('a b')
|
||||
})
|
||||
|
||||
it('escapes pipe characters inside service names', () => {
|
||||
const nodes = [makeNode({
|
||||
label: 'Server',
|
||||
services: [{ port: 80, protocol: 'tcp', service_name: 'web|proxy' }],
|
||||
})]
|
||||
const lines = generateMarkdownTable(nodes).split('\n')
|
||||
// header + separator + exactly one data row — the pipe must not add a column
|
||||
expect(lines).toHaveLength(3)
|
||||
expect(lines[2]).toContain('web\\|proxy')
|
||||
})
|
||||
|
||||
it('generates one row per non-groupRect node', () => {
|
||||
const nodes = [
|
||||
makeNode({ type: 'server', label: 'A' }, '1'),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
|
||||
|
||||
describe('ipDisplay persistence', () => {
|
||||
beforeEach(() => localStorage.clear())
|
||||
|
||||
it('defaults to false when nothing is stored', () => {
|
||||
expect(readHideIp()).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips true', () => {
|
||||
writeHideIp(true)
|
||||
expect(localStorage.getItem('homelable.hideIp')).toBe('true')
|
||||
expect(readHideIp()).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips false', () => {
|
||||
writeHideIp(true)
|
||||
writeHideIp(false)
|
||||
expect(readHideIp()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -75,8 +75,8 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
pos_y: n.position.y,
|
||||
custom_colors: {
|
||||
...n.data.custom_colors,
|
||||
width: n.measured?.width ?? n.width ?? 360,
|
||||
height: n.measured?.height ?? n.height ?? 240,
|
||||
width: n.width ?? n.measured?.width ?? 360,
|
||||
height: n.height ?? n.measured?.height ?? 240,
|
||||
// Stash collapse state inside custom_colors so the API/YAML blob does
|
||||
// not need a new column. Hoisted back to `data.collapsed` on load.
|
||||
collapsed: n.data.collapsed ?? false,
|
||||
@@ -112,8 +112,11 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
// Prefer the explicit (resized) dimension over the DOM-measured one so a
|
||||
// manual resize persists its exact target instead of drifting to the
|
||||
// fractional content-fit value.
|
||||
width: n.width ?? n.measured?.width ?? null,
|
||||
height: n.height ?? n.measured?.height ?? null,
|
||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||
show_port_numbers: n.data.show_port_numbers ?? false,
|
||||
pos_x: n.position.x,
|
||||
@@ -179,11 +182,17 @@ export function deserializeApiNode(
|
||||
collapsed: Boolean(n.custom_colors?.collapsed),
|
||||
} as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
// Container hosts (Proxmox/VM/LXC/docker in container_mode) get a default
|
||||
// box if none was saved. Every other node — including LEAF vm/lxc/docker
|
||||
// nodes nested inside a container — restores its own saved width/height.
|
||||
// Gating on container_mode (not type) is what keeps a resized nested node
|
||||
// from snapping back to content-fit on reload.
|
||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
: {}),
|
||||
...(n.width && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { width: n.width } : {}),
|
||||
...(n.height && !['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) ? { height: n.height } : {}),
|
||||
: {
|
||||
...(n.width ? { width: n.width } : {}),
|
||||
...(n.height ? { height: n.height } : {}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
LayoutDashboard, Zap, Network, Server, HardDrive, Cpu, Wifi, Router,
|
||||
Database, Cloud, Home, Globe, Lightbulb, Factory, Plug, Boxes,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface DesignIconEntry {
|
||||
key: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
/** Curated icon set offered when creating/editing a canvas design. Keys are
|
||||
* stable strings persisted on `Design.icon`. */
|
||||
export const DESIGN_ICONS: DesignIconEntry[] = [
|
||||
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ key: 'network', label: 'Network', icon: Network },
|
||||
{ key: 'zap', label: 'Electrical', icon: Zap },
|
||||
{ key: 'server', label: 'Server', icon: Server },
|
||||
{ key: 'harddrive', label: 'Storage', icon: HardDrive },
|
||||
{ key: 'cpu', label: 'Compute', icon: Cpu },
|
||||
{ key: 'wifi', label: 'Wireless', icon: Wifi },
|
||||
{ key: 'router', label: 'Router', icon: Router },
|
||||
{ key: 'database', label: 'Database', icon: Database },
|
||||
{ key: 'cloud', label: 'Cloud', icon: Cloud },
|
||||
{ key: 'home', label: 'Home', icon: Home },
|
||||
{ key: 'globe', label: 'Internet', icon: Globe },
|
||||
{ key: 'lightbulb', label: 'Lighting', icon: Lightbulb },
|
||||
{ key: 'factory', label: 'Industrial', icon: Factory },
|
||||
{ key: 'plug', label: 'Power', icon: Plug },
|
||||
{ key: 'boxes', label: 'Cluster', icon: Boxes },
|
||||
]
|
||||
|
||||
export const DEFAULT_DESIGN_ICON = 'dashboard'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
DESIGN_ICONS.map((e) => [e.key, e.icon]),
|
||||
)
|
||||
|
||||
/** Resolve a persisted design icon key to a lucide component. Unknown/empty
|
||||
* keys fall back to the dashboard icon so the UI never breaks on legacy data. */
|
||||
export function resolveDesignIcon(key?: string | null): LucideIcon {
|
||||
return (key && ICON_MAP[key]) || LayoutDashboard
|
||||
}
|
||||
@@ -8,4 +8,5 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ const EMPTY = '—'
|
||||
|
||||
function cell(v: string | null | undefined): string {
|
||||
if (!v) return EMPTY
|
||||
// Escape pipe chars so they don't break the table
|
||||
return v.replace(/\|/g, '\\|')
|
||||
// Escape backslashes first, then pipes, and collapse newlines so they don't break the table
|
||||
return v
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\r?\n/g, ' ')
|
||||
}
|
||||
|
||||
export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||
@@ -27,7 +30,7 @@ export function generateMarkdownTable(nodes: Node<NodeData>[]): string {
|
||||
cell(d.ip),
|
||||
cell(d.hostname),
|
||||
cell(d.status),
|
||||
services,
|
||||
cell(services),
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Persisted client-side preference for masking IP addresses on the canvas.
|
||||
// Kept in localStorage (per-user UI preference, not canvas data) so it
|
||||
// survives a page reload.
|
||||
|
||||
const KEY = 'homelable.hideIp'
|
||||
|
||||
export function readHideIp(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function writeHideIp(value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(KEY, String(value))
|
||||
} catch {
|
||||
/* quota / SSR */
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Mail, MessageSquare, Phone,
|
||||
// Misc devices
|
||||
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
|
||||
// Electrical
|
||||
ToggleLeft,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -154,32 +156,46 @@ export const ICON_MAP: Record<string, LucideIcon> = Object.fromEntries(
|
||||
)
|
||||
|
||||
export const NODE_TYPE_DEFAULT_ICONS: Record<NodeType, LucideIcon> = {
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
laptop: Laptop,
|
||||
mobile: Smartphone,
|
||||
cpl: PlugZap,
|
||||
docker_host: Anchor,
|
||||
docker_container: Package,
|
||||
isp: Globe,
|
||||
router: Router,
|
||||
firewall: Flame,
|
||||
switch: Network,
|
||||
server: Server,
|
||||
proxmox: Layers,
|
||||
vm: Box,
|
||||
lxc: Container,
|
||||
nas: HardDrive,
|
||||
iot: Cpu,
|
||||
ap: Wifi,
|
||||
camera: Cctv,
|
||||
printer: Printer,
|
||||
computer: Monitor,
|
||||
laptop: Laptop,
|
||||
mobile: Smartphone,
|
||||
cpl: PlugZap,
|
||||
docker_host: Anchor,
|
||||
docker_container: Package,
|
||||
zigbee_coordinator: Radio,
|
||||
zigbee_router: Zap,
|
||||
zigbee_enddevice: Lightbulb,
|
||||
generic: Circle,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
text: Type,
|
||||
group: Circle,
|
||||
groupRect: Circle,
|
||||
text: Type,
|
||||
grid: Zap,
|
||||
ups: Power,
|
||||
battery: BatteryCharging,
|
||||
generator: Fan,
|
||||
solar_panel: Sun,
|
||||
inverter: Workflow,
|
||||
circuit_breaker: PlugZap,
|
||||
contactor: ToggleLeft,
|
||||
electrical_switch: ToggleLeft,
|
||||
socket: Plug,
|
||||
light: Lightbulb,
|
||||
meter: Gauge,
|
||||
transformer: CircleDot,
|
||||
load: Circle,
|
||||
}
|
||||
|
||||
/** Resolve the display icon for a node — custom_icon takes priority over type default.
|
||||
|
||||
@@ -68,6 +68,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
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',
|
||||
nodeIconBackground: '#161b22',
|
||||
@@ -87,6 +101,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical:'#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
@@ -132,6 +147,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
group: { border: '#22d3ee', icon: '#22d3ee' },
|
||||
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',
|
||||
nodeIconBackground: '#111111',
|
||||
@@ -151,15 +180,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#6b7280',
|
||||
cluster: '#fb923c',
|
||||
fibre: '#06b6d4',
|
||||
electrical:'#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#22d3ee',
|
||||
edgeLabelBackground:'#111111',
|
||||
edgeLabelColor: '#666666',
|
||||
edgeLabelBorder: '#1c1c1e',
|
||||
canvasBackground: '#000000',
|
||||
canvasDotColor: '#1a1a1a',
|
||||
handleBackground: '#1c1c1e',
|
||||
handleBorder: '#444444',
|
||||
edgeLabelBackground:'#161b22',
|
||||
edgeLabelColor: '#9ca3af',
|
||||
edgeLabelBorder: '#374151',
|
||||
canvasBackground: '#030712',
|
||||
canvasDotColor: '#374151',
|
||||
handleBackground: '#374151',
|
||||
handleBorder: '#9ca3af',
|
||||
reactFlowColorMode: 'dark',
|
||||
},
|
||||
},
|
||||
@@ -196,6 +226,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#0284c7', icon: '#0284c7' },
|
||||
group: { border: '#0284c7', icon: '#0284c7' },
|
||||
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',
|
||||
nodeIconBackground: '#f0f6ff',
|
||||
@@ -215,15 +259,16 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#9ca3af',
|
||||
cluster: '#ea580c',
|
||||
fibre: '#0891b2',
|
||||
electrical:'#d97706',
|
||||
},
|
||||
edgeSelectedColor: '#0284c7',
|
||||
edgeLabelBackground:'#ffffff',
|
||||
edgeLabelColor: '#57606a',
|
||||
edgeLabelColor: '#6b7280',
|
||||
edgeLabelBorder: '#d0d7de',
|
||||
canvasBackground: '#f6f8fa',
|
||||
canvasDotColor: '#d0d7de',
|
||||
handleBackground: '#d0d7de',
|
||||
handleBorder: '#9ca3af',
|
||||
handleBorder: '#6b7280',
|
||||
reactFlowColorMode: 'light',
|
||||
},
|
||||
},
|
||||
@@ -260,6 +305,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00ffff', icon: '#00ffff' },
|
||||
group: { border: '#00ffff', icon: '#00ffff' },
|
||||
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',
|
||||
nodeIconBackground: '#0a0a1a',
|
||||
@@ -279,6 +338,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8888cc',
|
||||
cluster: '#ff8800',
|
||||
fibre: '#00e5ff',
|
||||
electrical:'#ffff00',
|
||||
},
|
||||
edgeSelectedColor: '#00ffff',
|
||||
edgeLabelBackground:'#0a0a1a',
|
||||
@@ -324,6 +384,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00ff41', icon: '#00ff41' },
|
||||
group: { border: '#00ff41', 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',
|
||||
nodeIconBackground: '#002200',
|
||||
@@ -343,6 +417,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#004400',
|
||||
cluster: '#33ff66',
|
||||
fibre: '#00ffcc',
|
||||
electrical:'#66ff33',
|
||||
},
|
||||
edgeSelectedColor: '#00ff41',
|
||||
edgeLabelBackground:'#001100',
|
||||
@@ -388,6 +463,20 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
groupRect: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
group: { border: '#00d4ff', icon: '#00d4ff' },
|
||||
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',
|
||||
nodeIconBackground: '#161b22',
|
||||
@@ -407,6 +496,7 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
|
||||
Reference in New Issue
Block a user