Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 3b3cc1eba0 | |||
| 3da5517996 | |||
| cb26da3de5 | |||
| 23a0a47a7f | |||
| aac6c09a04 | |||
| bf90d6312b | |||
| ec15c260e1 | |||
| 47ab3a9a76 | |||
| eb7b0c6d38 | |||
| be320c2ac4 | |||
| 60ce648a96 | |||
| d5e35a885d | |||
| 3a57d809a4 | |||
| 46435605eb | |||
| e8bcf04b46 | |||
| 4ba04660c8 | |||
| 4b06ce6ef6 | |||
| 6761f73c17 | |||
| 1431f5b19e | |||
| b5628e18fa | |||
| dc70805673 | |||
| 05c24d622b | |||
| 541e25327b | |||
| 9823be9d78 | |||
| b71c96897a | |||
| 20e1820a4e | |||
| 517486ff79 | |||
| b5b1056ae6 | |||
| 66a9a57861 | |||
| 529c75a175 | |||
| fc765fa255 | |||
| 77159ce1cd | |||
| 69aa8256f0 | |||
| 78b43a300f | |||
| 995de26591 | |||
| 525dfe5ece | |||
| d066f37e88 | |||
| 004623bae5 | |||
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 | |||
| 1790d24ee7 | |||
| 8b97f578c6 | |||
| 48e7b3c690 | |||
| 3a4df578ec | |||
| 43426e96ac | |||
| 280d5a2ae1 | |||
| cdc6091bd0 | |||
| 14597ab86f | |||
| 14719a5c39 | |||
| ea451885af | |||
| 9cd93ef294 | |||
| 8d937b2a0d | |||
| 60ea0fa11b | |||
| ff0d33128c | |||
| 17d4ddd070 | |||
| 4795dc40b8 |
@@ -16,14 +16,21 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- image: ghcr.io/pouzor/homelable-backend
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: "VITE_STANDALONE=true"
|
||||
- image: ghcr.io/pouzor/homelable-mcp
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
build_args: ""
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -55,8 +62,8 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -223,6 +223,12 @@ docker compose up -d mcp
|
||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||
```
|
||||
|
||||
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
||||
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
||||
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
||||
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
||||
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
||||
|
||||
**3. Configure your AI client:**
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
|
||||
@@ -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,12 +53,20 @@ 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(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -20,6 +20,34 @@ from app.services.zigbee_service import build_zigbee_properties
|
||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||
|
||||
|
||||
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list carrying a device MAC address.
|
||||
|
||||
Shape matches the frontend ``NodeProperty`` type
|
||||
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
||||
showing it on the canvas card from the right panel. Returns an empty list
|
||||
when no MAC is known.
|
||||
"""
|
||||
if not mac:
|
||||
return []
|
||||
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
||||
|
||||
|
||||
def merge_mac_property(
|
||||
props: list[dict[str, Any]] | None, mac: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
||||
|
||||
Preserves any user-supplied properties (and an existing MAC row's
|
||||
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
||||
"""
|
||||
out = [dict(p) for p in (props or [])]
|
||||
if not mac or any(p.get("key") == "MAC" for p in out):
|
||||
return out
|
||||
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
||||
return out
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
|
||||
@@ -118,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),
|
||||
@@ -134,16 +166,18 @@ async def bulk_approve_devices(
|
||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
mac=device.mac,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_zigbee else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_zigbee else [],
|
||||
) if is_zigbee else build_mac_property(device.mac),
|
||||
# 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)
|
||||
@@ -227,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")
|
||||
@@ -234,19 +274,24 @@ async def approve_device(
|
||||
raise HTTPException(status_code=409, detail="Device already processed")
|
||||
device.status = "approved"
|
||||
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||
# fall back to whatever the approve payload carried.
|
||||
_mac = device.mac or node_data.mac
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
mac=_mac,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if _is_zigbee else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if _is_zigbee else (node_data.properties or []),
|
||||
) 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()
|
||||
@@ -328,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()
|
||||
|
||||
@@ -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()
|
||||
@@ -223,7 +228,13 @@ async def _persist_pending_import(
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
if pending.status == "hidden":
|
||||
if pending.status == "approved":
|
||||
# The device was approved earlier but its canvas Node no longer
|
||||
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
||||
# the row to "pending" so it reappears in the Pending list on
|
||||
# re-import instead of being silently swallowed. (Issue #167)
|
||||
pending.status = "pending"
|
||||
elif pending.status == "hidden":
|
||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||
pass
|
||||
pending_updated += 1
|
||||
|
||||
+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
|
||||
|
||||
@@ -15,7 +15,7 @@ pyyaml==6.0.2
|
||||
types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.131.0
|
||||
zeroconf==0.149.7
|
||||
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"
|
||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
payload = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["viewport"].get("theme_id") == "matrix"
|
||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -124,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()
|
||||
@@ -698,6 +698,144 @@ async def test_bulk_approve_zigbee_populates_properties(
|
||||
assert node.check_method == "none"
|
||||
|
||||
|
||||
# --- MAC address propagation on approve (issue #168) ---
|
||||
|
||||
def test_build_mac_property_returns_hidden_row():
|
||||
from app.api.routes.scan import build_mac_property
|
||||
|
||||
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
def test_build_mac_property_empty_when_no_mac():
|
||||
from app.api.routes.scan import build_mac_property
|
||||
|
||||
assert build_mac_property(None) == []
|
||||
assert build_mac_property("") == []
|
||||
|
||||
|
||||
def test_merge_mac_property_appends_when_absent():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
|
||||
# Existing prop preserved untouched.
|
||||
assert existing[0] in merged
|
||||
|
||||
|
||||
def test_merge_mac_property_idempotent_and_preserves_visibility():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
|
||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||
# No duplicate MAC row; user's visible=True choice kept.
|
||||
macs = [p for p in merged if p["key"] == "MAC"]
|
||||
assert len(macs) == 1
|
||||
assert macs[0]["visible"] is True
|
||||
|
||||
|
||||
def test_merge_mac_property_noop_without_mac():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||
assert merge_mac_property(existing, None) == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_copies_mac_to_node_and_properties(
|
||||
client: AsyncClient, headers, pending_device, db_session
|
||||
):
|
||||
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
# Payload intentionally omits mac — it must come from the pending device.
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_does_not_duplicate_mac_property(
|
||||
client: AsyncClient, headers, pending_device, db_session
|
||||
):
|
||||
"""If the approve payload already carries a MAC prop, don't add a second one."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={
|
||||
"label": "My Server",
|
||||
"type": "server",
|
||||
"ip": "192.168.1.100",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
"properties": [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
assert len(mac_props) == 1
|
||||
# User's visibility choice is preserved.
|
||||
assert mac_props[0]["visible"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.55",
|
||||
mac="11:22:33:44:55:66",
|
||||
hostname="host-mac",
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [device.id]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
||||
).scalar_one()
|
||||
assert node.mac == "11:22:33:44:55:66"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||
|
||||
@@ -459,6 +459,92 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||
assert all(p["visible"] is False for p in refreshed.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""Regression for #167: approve → delete node → re-import must re-list device.
|
||||
|
||||
When a device was approved (PendingDevice.status="approved") and its canvas
|
||||
Node was later deleted, the orphaned "approved" row must be reset to
|
||||
"pending" on re-import so it shows up in the Pending list again — instead of
|
||||
being silently swallowed (re-import reports "found" but Pending stays empty).
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
|
||||
# Simulate prior approve: a PendingDevice marked approved, but NO matching
|
||||
# Node exists (the user deleted the canvas node afterwards).
|
||||
orphan = PendingDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
hostname="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
device_subtype="Router",
|
||||
model="CC2530",
|
||||
vendor="TI",
|
||||
lqi=220,
|
||||
status="approved",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(orphan)
|
||||
await db_session.commit()
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
# No new row created for 0xR1 — the existing one was updated/revived.
|
||||
revived = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
# End device 0xE1 is brand new → created as pending; router was updated.
|
||||
assert result.pending_created == 1
|
||||
assert result.pending_updated == 1
|
||||
|
||||
# It is now visible to the Pending list (status filter == "pending").
|
||||
listed = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||
)
|
||||
).scalars().all()
|
||||
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
|
||||
hidden = PendingDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
device_subtype="Router",
|
||||
status="hidden",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(hidden)
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
still_hidden = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
||||
"""If user has already made props visible, re-import must not flip them back."""
|
||||
|
||||
@@ -24,6 +24,20 @@ services:
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
mcp:
|
||||
image: ghcr.io/pouzor/homelable-mcp:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: "http://backend:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
|
||||
|
||||
Generated
+77
-14
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "2.0.3",
|
||||
"version": "2.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "2.0.3",
|
||||
"version": "2.4.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
@@ -2520,6 +2520,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2534,6 +2537,9 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2548,6 +2554,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2562,6 +2571,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2576,6 +2588,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2590,6 +2605,9 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2604,6 +2622,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2618,6 +2639,9 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2632,6 +2656,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2646,6 +2673,9 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2660,6 +2690,9 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2674,6 +2707,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2688,6 +2724,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2936,6 +2975,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2953,6 +2995,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2970,6 +3015,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2987,6 +3035,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3186,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"
|
||||
@@ -3600,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": {
|
||||
@@ -4199,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": {
|
||||
@@ -6884,6 +6935,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6905,6 +6959,9 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6926,6 +6983,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -6947,6 +7007,9 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7977,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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "2.0.3",
|
||||
"version": "2.4.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+163
-46
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { ExportModal } from '@/components/modals/ExportModal'
|
||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||
@@ -19,6 +20,7 @@ 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'
|
||||
@@ -27,9 +29,10 @@ import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
@@ -37,13 +40,13 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||
|
||||
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 } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
@@ -67,32 +70,79 @@ export default function App() {
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | 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 +162,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 +228,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)
|
||||
@@ -384,6 +455,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 }
|
||||
@@ -450,16 +543,14 @@ export default function App() {
|
||||
if (edgeData.type === 'virtual') {
|
||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||
const srcType = src?.data.type as NodeData['type']
|
||||
const tgtType = tgt?.data.type as NodeData['type']
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||
if (src && tgt) {
|
||||
const assignment = resolveVirtualEdgeParent(
|
||||
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||
)
|
||||
if (assignment) {
|
||||
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||
}
|
||||
}
|
||||
}
|
||||
setPendingConnection(null)
|
||||
@@ -515,6 +606,7 @@ export default function App() {
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
forceView={sidebarForceView}
|
||||
onOpenPending={openPendingModal}
|
||||
/>
|
||||
@@ -530,6 +622,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">
|
||||
@@ -552,6 +645,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 }))}
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
@@ -562,6 +656,25 @@ export default function App() {
|
||||
onSubmit={handleUpdateNode}
|
||||
initial={editNode?.data}
|
||||
title="Edit Node"
|
||||
parentCandidates={(() => {
|
||||
const descendants = new Set<string>()
|
||||
if (editNodeId) {
|
||||
const queue = [editNodeId]
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
for (const n of nodes) {
|
||||
if (n.data.parent_id === id && !descendants.has(n.id)) {
|
||||
descendants.add(n.id)
|
||||
queue.push(n.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => !descendants.has(n.id))
|
||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))
|
||||
})()}
|
||||
currentNodeId={editNodeId ?? undefined}
|
||||
/>
|
||||
|
||||
<EdgeModal
|
||||
@@ -691,6 +804,10 @@ export default function App() {
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
{!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')
|
||||
|
||||
@@ -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 = {
|
||||
@@ -89,6 +95,15 @@ export const settingsApi = {
|
||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||
}
|
||||
|
||||
export const designsApi = {
|
||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||
create: (data: { name: string; 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 = {
|
||||
testConnection: (data: {
|
||||
mqtt_host: string
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* Clicking a node with an IP opens http://<ip> in a new tab.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ReactFlowProvider,
|
||||
ReactFlow,
|
||||
@@ -28,8 +28,9 @@ import { THEMES } from '@/utils/themes'
|
||||
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeData, CustomStyleDef } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
@@ -40,6 +41,8 @@ function LiveViewCanvas() {
|
||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||
const theme = THEMES[activeTheme]
|
||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||
// - standalone → always ready (localStorage, no key required)
|
||||
@@ -65,17 +68,24 @@ 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>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n: ApiNode) => [n.id, true])
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
@@ -87,7 +97,7 @@ function LiveViewCanvas() {
|
||||
const detail: string = err.response.data?.detail ?? ''
|
||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||
})
|
||||
}, [loadCanvas])
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
@@ -103,6 +113,18 @@ function LiveViewCanvas() {
|
||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
// Apply collapse-state filtering — same pipeline the editor canvas uses,
|
||||
// so a collapsed group/zone hides its contents in live view too.
|
||||
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
|
||||
const visibleNodes = useMemo(
|
||||
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
|
||||
[nodes, collapseInfo],
|
||||
)
|
||||
const visibleEdges = useMemo(
|
||||
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
|
||||
[edges, nodes, collapseInfo],
|
||||
)
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
||||
@@ -131,8 +153,8 @@ function LiveViewCanvas() {
|
||||
return (
|
||||
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={false}
|
||||
@@ -140,6 +162,8 @@ function LiveViewCanvas() {
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
maxZoom={2.5}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodeClick={onNodeClick}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
// ── Capture the props ReactFlow is rendered with ──────────────────────────
|
||||
const rfPropsSpy = vi.fn()
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: (props: unknown) => {
|
||||
rfPropsSpy(props)
|
||||
return <div data-testid="react-flow" />
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
vi.mock('@/api/client', () => ({ liveviewApi: { load: vi.fn() } }))
|
||||
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import LiveView from '../LiveView'
|
||||
|
||||
function setSearch(params: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, search: params, pathname: '/view' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Build a /liveview API response with the given nodes/edges. */
|
||||
const apiResponse = (nodes: unknown[], edges: unknown[] = []) => ({
|
||||
data: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
})
|
||||
|
||||
const apiNode = (
|
||||
id: string,
|
||||
parent_id?: string,
|
||||
collapsed?: boolean,
|
||||
type = 'server',
|
||||
) => ({
|
||||
id,
|
||||
type,
|
||||
label: id,
|
||||
status: 'online',
|
||||
services: [],
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
parent_id: parent_id ?? null,
|
||||
container_mode: type === 'group',
|
||||
custom_colors: collapsed !== undefined ? { collapsed } : null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
describe('LiveView — applies collapse filter to the rendered canvas', () => {
|
||||
beforeEach(() => {
|
||||
rfPropsSpy.mockClear()
|
||||
setSearch('?key=valid')
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
})
|
||||
|
||||
it('hides children of a collapsed group container in view-only mode', async () => {
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||
apiResponse([apiNode('g1', undefined, true, 'group'), apiNode('c1', 'g1')]),
|
||||
)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => {
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||
| { nodes: Node<NodeData>[] }
|
||||
| undefined
|
||||
expect(last?.nodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
}
|
||||
const ids = last.nodes.map((n) => n.id)
|
||||
expect(ids).toContain('g1')
|
||||
expect(ids).not.toContain('c1')
|
||||
})
|
||||
|
||||
it('shows children when the group is expanded', async () => {
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||
apiResponse([apiNode('g1', undefined, false, 'group'), apiNode('c1', 'g1')]),
|
||||
)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => {
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||
| { nodes: Node<NodeData>[] }
|
||||
| undefined
|
||||
expect(last?.nodes.length).toBeGreaterThan(1)
|
||||
})
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||
nodes: Node<NodeData>[]
|
||||
}
|
||||
const ids = last.nodes.map((n) => n.id)
|
||||
expect(ids).toContain('g1')
|
||||
expect(ids).toContain('c1')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
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' },
|
||||
@@ -48,6 +55,7 @@ const canvasPayload = {
|
||||
|
||||
describe('LiveView (non-standalone)', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||
})
|
||||
@@ -110,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 () => {
|
||||
@@ -124,6 +151,55 @@ describe('LiveView (non-standalone)', () => {
|
||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||
|
||||
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||
setSearch('?key=valid')
|
||||
const nestedPayload = {
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||
expect(ctr?.parentId).toBe('host')
|
||||
expect(ctr?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||
|
||||
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||
setSearch('?key=valid')
|
||||
const styledPayload = {
|
||||
data: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||
})
|
||||
|
||||
// ── No editing props passed ───────────────────────────────────────────────
|
||||
|
||||
it('does not show any Access Denied when key is valid', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -17,6 +17,7 @@ import '@xyflow/react/dist/style.css'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
@@ -39,8 +40,34 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
copySelectedNodes, pasteNodes,
|
||||
} = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const { fitView, screenToFlowPosition } = useReactFlow()
|
||||
|
||||
// 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(() => {
|
||||
@@ -55,6 +82,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
|
||||
// Filter nodes and edges based on collapsed state (memoized — O(n)).
|
||||
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
|
||||
const visibleNodes = useMemo(
|
||||
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
|
||||
[nodes, collapseInfo],
|
||||
)
|
||||
const visibleEdges = useMemo(
|
||||
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
|
||||
[edges, nodes, collapseInfo],
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedNode(null)
|
||||
@@ -88,10 +126,10 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
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={nodes}
|
||||
edges={edges}
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnectProp}
|
||||
|
||||
@@ -59,7 +59,10 @@ vi.mock('@/utils/propertyIcons', () => ({
|
||||
|
||||
vi.mock('@/utils/handleUtils', () => ({
|
||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||
bottomHandlePositions: () => [50],
|
||||
bottomHandlePositions: (count: number) => {
|
||||
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
|
||||
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
||||
},
|
||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
||||
}))
|
||||
|
||||
@@ -171,6 +174,29 @@ describe('BaseNode — properties rendering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — port numbers (issue #20)', () => {
|
||||
it('renders a number above each bottom handle when show_port_numbers is on', () => {
|
||||
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
||||
expect(screen.getByText('1')).toBeDefined()
|
||||
expect(screen.getByText('2')).toBeDefined()
|
||||
expect(screen.getByText('3')).toBeDefined()
|
||||
expect(screen.getByText('4')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render port numbers when show_port_numbers is off', () => {
|
||||
renderBaseNode({ bottom_handles: 4 })
|
||||
expect(screen.queryByText('1')).toBeNull()
|
||||
expect(screen.queryByText('4')).toBeNull()
|
||||
})
|
||||
|
||||
it('numbers match the handle count', () => {
|
||||
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
||||
expect(screen.getByText('1')).toBeDefined()
|
||||
expect(screen.getByText('2')).toBeDefined()
|
||||
expect(screen.queryByText('3')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BaseNode — services visibility toggle', () => {
|
||||
it('does not render service toggle button on the node', () => {
|
||||
renderBaseNode({ services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }] })
|
||||
|
||||
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||
),
|
||||
Handle: () => null,
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({}),
|
||||
}))
|
||||
|
||||
@@ -40,6 +42,7 @@ function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, s
|
||||
nodes: storeNodes,
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
toggleNodeCollapsed: vi.fn(),
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
|
||||
return render(
|
||||
@@ -132,4 +135,51 @@ describe('GroupNode', () => {
|
||||
renderGroupNode()
|
||||
expect(screen.queryByText(/●/)).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a collapse toggle when the group has parentId children', () => {
|
||||
const storeNodes = [
|
||||
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||
{ id: 'c2', parentId: 'g1', data: { status: 'online' } },
|
||||
]
|
||||
renderGroupNode({}, storeNodes)
|
||||
expect(screen.getByTitle('Hide 2 items')).toBeDefined()
|
||||
})
|
||||
|
||||
it('flips the toggle title when collapsed', () => {
|
||||
const storeNodes = [
|
||||
{ id: 'c1', parentId: 'g1', data: { status: 'online' } },
|
||||
]
|
||||
renderGroupNode({ data: makeGroupNode({ collapsed: true }).data }, storeNodes)
|
||||
expect(screen.getByTitle('Show 1 hidden items')).toBeDefined()
|
||||
})
|
||||
|
||||
it('calls toggleNodeCollapsed when the toggle is clicked', () => {
|
||||
const toggleNodeCollapsed = vi.fn()
|
||||
const storeNodes = [{ id: 'c1', parentId: 'g1', data: { status: 'online' } }]
|
||||
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
|
||||
nodes: storeNodes,
|
||||
updateNode: vi.fn(),
|
||||
snapshotHistory: vi.fn(),
|
||||
toggleNodeCollapsed,
|
||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||
render(
|
||||
<GroupNode
|
||||
id="g1"
|
||||
data={makeGroupNode().data}
|
||||
selected={false}
|
||||
dragging={false}
|
||||
zIndex={1}
|
||||
isConnectable={true}
|
||||
positionAbsoluteX={0}
|
||||
positionAbsoluteY={0}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Hide 1 items'))
|
||||
expect(toggleNodeCollapsed).toHaveBeenCalledWith('g1')
|
||||
})
|
||||
|
||||
it('does not render the toggle when the group has no children', () => {
|
||||
renderGroupNode()
|
||||
expect(screen.queryByTitle(/Hide.*items|Show.*hidden/)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,17 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { edgeTypes } from '../edgeTypes'
|
||||
import { EDGE_TYPE_LABELS, type EdgeType } from '@/types'
|
||||
|
||||
describe('edgeTypes registry', () => {
|
||||
// Regression (issue #21): an EdgeType missing here makes React Flow fall back
|
||||
// to its built-in default edge — grey, unstyled, ignoring custom_color.
|
||||
it('registers a component for every EdgeType', () => {
|
||||
for (const type of Object.keys(EDGE_TYPE_LABELS) as EdgeType[]) {
|
||||
expect(edgeTypes[type as keyof typeof edgeTypes]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('registers fibre', () => {
|
||||
expect(edgeTypes.fibre).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -7,4 +7,6 @@ export const edgeTypes = {
|
||||
vlan: HomelableEdge,
|
||||
virtual: HomelableEdge,
|
||||
cluster: HomelableEdge,
|
||||
fibre: HomelableEdge,
|
||||
electrical: HomelableEdge,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
@@ -179,9 +179,113 @@ function segmentMidpoints(
|
||||
})
|
||||
}
|
||||
|
||||
// ── Endpoint dot (interactive reconnection handle pinned to handle) ──────────
|
||||
|
||||
interface EndpointDotProps {
|
||||
edgeId: string
|
||||
role: 'source' | 'target'
|
||||
x: number
|
||||
y: number
|
||||
position?: string
|
||||
color: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle: string | null | undefined
|
||||
targetHandle: string | null | undefined
|
||||
onDrag: (pos: { x: number; y: number } | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive endpoint marker rendered above the node layer (via
|
||||
* EdgeLabelRenderer). On pointerup it inspects the element under the cursor
|
||||
* for a React Flow handle (`[data-handleid]`) and calls `reconnectEdge` with
|
||||
* the new endpoint. Drop on empty space leaves the edge unchanged.
|
||||
*
|
||||
* Handles are nudged 3px inward (toward the node) because React Flow's edge
|
||||
* endpoint coords sit at the outer edge of the handle box, not its center.
|
||||
*/
|
||||
function EndpointDot({ edgeId, role, x, y, position, color, source, target, sourceHandle, targetHandle, onDrag }: EndpointDotProps) {
|
||||
const reconnectEdge = useCanvasStore((s) => s.reconnectEdge)
|
||||
const { screenToFlowPosition } = useReactFlow()
|
||||
|
||||
const offset = 3
|
||||
let dx = 0, dy = 0
|
||||
if (position === 'bottom') dy = -offset
|
||||
else if (position === 'top') dy = offset
|
||||
else if (position === 'left') dx = offset
|
||||
else if (position === 'right') dx = -offset
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
||||
e.stopPropagation()
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
||||
if (e.buttons !== 1) return
|
||||
onDrag(screenToFlowPosition({ x: e.clientX, y: e.clientY }))
|
||||
}, [onDrag, screenToFlowPosition])
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
// Find the topmost handle under cursor, skipping the dragged dot itself.
|
||||
const stack = document.elementsFromPoint(e.clientX, e.clientY)
|
||||
let handleEl: HTMLElement | null = null
|
||||
for (const node of stack) {
|
||||
const h = (node as HTMLElement).closest?.('[data-handleid]') as HTMLElement | null
|
||||
if (h) { handleEl = h; break }
|
||||
}
|
||||
onDrag(null)
|
||||
if (!handleEl) return // dropped on empty space → keep edge unchanged
|
||||
const newHandleId = handleEl.getAttribute('data-handleid')
|
||||
const newNodeId = handleEl.getAttribute('data-nodeid')
|
||||
if (!newHandleId || !newNodeId) return
|
||||
if (role === 'source') {
|
||||
reconnectEdge(edgeId, { source: newNodeId, target, sourceHandle: newHandleId, targetHandle: targetHandle ?? null })
|
||||
} else {
|
||||
reconnectEdge(edgeId, { source, target: newNodeId, sourceHandle: sourceHandle ?? null, targetHandle: newHandleId })
|
||||
}
|
||||
}, [edgeId, role, source, target, sourceHandle, targetHandle, reconnectEdge, onDrag])
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${x + dx}px, ${y + dy}px)`,
|
||||
width: 15,
|
||||
height: 15,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
border: '2px solid #0d1117',
|
||||
cursor: 'grab',
|
||||
pointerEvents: 'all',
|
||||
zIndex: 1000,
|
||||
touchAction: 'none',
|
||||
}}
|
||||
title="Drag to reconnect"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main edge component ──────────────────────────────────────────────────────
|
||||
|
||||
export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
export function HomelableEdge({ id, source, target, sourceHandleId, targetHandleId, sourceX: rawSourceX, sourceY: rawSourceY, targetX: rawTargetX, targetY: rawTargetY, sourcePosition, targetPosition, data, selected }: EdgeProps<Edge<EdgeData>>) {
|
||||
const [drag, setDrag] = useState<{ role: 'source' | 'target'; x: number; y: number } | null>(null)
|
||||
|
||||
const sourceX = drag?.role === 'source' ? drag.x : rawSourceX
|
||||
const sourceY = drag?.role === 'source' ? drag.y : rawSourceY
|
||||
const targetX = drag?.role === 'target' ? drag.x : rawTargetX
|
||||
const targetY = drag?.role === 'target' ? drag.y : rawTargetY
|
||||
|
||||
const onSourceDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||
setDrag(pos ? { role: 'source', x: pos.x, y: pos.y } : null)
|
||||
}, [])
|
||||
const onTargetDrag = useCallback((pos: { x: number; y: number } | null) => {
|
||||
setDrag(pos ? { role: 'target', x: pos.x, y: pos.y } : null)
|
||||
}, [])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||
@@ -219,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
vlan: { strokeWidth: 2.5 },
|
||||
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
|
||||
@@ -272,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
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
|
||||
@@ -290,10 +396,11 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
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>
|
||||
@@ -311,6 +418,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Endpoint dots — visual indicators for reconnection targets */}
|
||||
{selected && (
|
||||
<>
|
||||
<EndpointDot
|
||||
edgeId={id}
|
||||
role="source"
|
||||
x={sourceX}
|
||||
y={sourceY}
|
||||
position={sourcePosition}
|
||||
color={strokeColor}
|
||||
source={source}
|
||||
target={target}
|
||||
sourceHandle={sourceHandleId}
|
||||
targetHandle={targetHandleId}
|
||||
onDrag={onSourceDrag}
|
||||
/>
|
||||
<EndpointDot
|
||||
edgeId={id}
|
||||
role="target"
|
||||
x={targetX}
|
||||
y={targetY}
|
||||
position={targetPosition}
|
||||
color={strokeColor}
|
||||
source={source}
|
||||
target={target}
|
||||
sourceHandle={sourceHandleId}
|
||||
targetHandle={targetHandleId}
|
||||
onDrag={onTargetDrag}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Existing waypoint drag handles */}
|
||||
{selected && waypoints.map((wp, idx) => {
|
||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||
|
||||
@@ -254,6 +254,20 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
{data.show_port_numbers && (
|
||||
<span
|
||||
className="absolute font-mono leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${leftPct}%`,
|
||||
bottom: 3,
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 7,
|
||||
color: theme.colors.nodeSubtextColor,
|
||||
}}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
)}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState } from 'react'
|
||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
||||
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
|
||||
import { Layers, Pencil, Check, X, ChevronDown } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||
|
||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const { nodes, updateNode, snapshotHistory } = useCanvasStore()
|
||||
const { nodes, updateNode, snapshotHistory, toggleNodeCollapsed } = useCanvasStore()
|
||||
const isCollapsed = data.collapsed ?? false
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
const showBorder = data.custom_colors?.show_border !== false
|
||||
const isVisible = showBorder || selected
|
||||
|
||||
@@ -49,6 +54,29 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
handleStyle={{ fill: '#00d4ff', stroke: '#0d1117', width: 8, height: 8, borderRadius: 2 }}
|
||||
/>
|
||||
|
||||
{/* 4 snap-point handles — one per side. Source + invisible target overlay for each. */}
|
||||
{([
|
||||
['group-top', Position.Top],
|
||||
['group-right', Position.Right],
|
||||
['group-bottom', Position.Bottom],
|
||||
['group-left', Position.Left],
|
||||
] as const).map(([hid, pos]) => (
|
||||
<span key={hid}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={pos}
|
||||
id={hid}
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={pos}
|
||||
id={`${hid}-t`}
|
||||
style={{ opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Header */}
|
||||
{isVisible && (
|
||||
<div
|
||||
@@ -111,6 +139,28 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Collapse / expand toggle */}
|
||||
{children.length > 0 && (
|
||||
<button
|
||||
className="nodrag"
|
||||
onClick={(e) => { e.stopPropagation(); toggleNodeCollapsed(id) }}
|
||||
title={isCollapsed ? `Show ${children.length} hidden items` : `Hide ${children.length} items`}
|
||||
style={{
|
||||
color: '#00d4ff',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
transition: 'transform 0.2s ease-out',
|
||||
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
>
|
||||
<ChevronDown size={11} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Status summary */}
|
||||
{children.length > 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { getZoneSpatialChildren } from '@/utils/collapseFilter'
|
||||
import type { NodeData, TextPosition } from '@/types'
|
||||
|
||||
const FONT_FAMILIES: Record<string, string> = {
|
||||
@@ -36,9 +38,12 @@ const HANDLE_SIDES = [
|
||||
|
||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||
const toggleNodeCollapsed = useCanvasStore((s) => s.toggleNodeCollapsed)
|
||||
const nodes = useCanvasStore((s) => s.nodes)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
const rc = data.custom_colors ?? {}
|
||||
const isCollapsed = data.collapsed ?? false
|
||||
const borderColor = rc.border ?? '#00d4ff'
|
||||
const borderStyle = rc.border_style ?? 'solid'
|
||||
const borderWidth = rc.border_width ?? 2
|
||||
@@ -50,6 +55,13 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||
const posStyle = POSITION_STYLES[textPos]
|
||||
|
||||
// Count children for collapse badge — groupRect zones don't parent their
|
||||
// contents via React Flow parentId, so we hit-test by spatial containment.
|
||||
const selfNode = (nodes ?? []).find((n) => n.id === id)
|
||||
const childrenCount = selfNode
|
||||
? getZoneSpatialChildren(selfNode, nodes ?? []).length
|
||||
: 0
|
||||
|
||||
const outsideJustify = textPos.includes('right') ? 'flex-end'
|
||||
: (textPos.includes('center') || textPos === 'center') ? 'center'
|
||||
: 'flex-start'
|
||||
@@ -118,6 +130,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
borderRadius: 10,
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'default',
|
||||
transition: 'opacity 0.2s ease-out, filter 0.2s ease-out',
|
||||
opacity: isCollapsed ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
@@ -126,6 +140,51 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
||||
setEditingGroupRectId(id)
|
||||
}}
|
||||
>
|
||||
{childrenCount > 0 && (
|
||||
<button
|
||||
className="nodrag"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleNodeCollapsed(id)
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: 0,
|
||||
background: 'rgba(0, 212, 255, 0.1)',
|
||||
border: '1px solid rgba(0, 212, 255, 0.3)',
|
||||
borderRadius: 4,
|
||||
color: borderColor,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.2s ease-out, transform 0.2s ease-out',
|
||||
transform: isCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
|
||||
}}
|
||||
title={isCollapsed ? `Show ${childrenCount} hidden items` : `Hide ${childrenCount} items`}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
)}
|
||||
{isCollapsed && childrenCount > 0 && (
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 6,
|
||||
right: 6,
|
||||
fontSize: 10,
|
||||
color: borderColor,
|
||||
opacity: 0.7,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
+{childrenCount}
|
||||
</span>
|
||||
)}
|
||||
{labelPosition === 'outside' && data.label && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createElement } from 'react'
|
||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||
import { Layers } from 'lucide-react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
@@ -10,10 +10,13 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||
import { BaseNode } from './BaseNode'
|
||||
|
||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
const { data, selected } = props
|
||||
const { id, data, selected } = props
|
||||
const updateNodeInternals = useUpdateNodeInternals()
|
||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||
@@ -149,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="bottom"
|
||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||
const sourceId = bottomHandleId(idx)
|
||||
const targetId = `${sourceId}-t`
|
||||
return (
|
||||
<span key={sourceId}>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={sourceId}
|
||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Bottom}
|
||||
id={targetId}
|
||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Cluster handles */}
|
||||
<Handle
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { GroupNode } from '../GroupNode'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeProps, Node } from '@xyflow/react'
|
||||
|
||||
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||
const fullData: NodeData = {
|
||||
label: 'Group A',
|
||||
type: 'group',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
...data,
|
||||
}
|
||||
const props = {
|
||||
id: 'g1',
|
||||
data: fullData,
|
||||
selected,
|
||||
type: 'group',
|
||||
zIndex: 0,
|
||||
isConnectable: true,
|
||||
xPos: 0,
|
||||
yPos: 0,
|
||||
dragging: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
positionAbsoluteX: 0,
|
||||
positionAbsoluteY: 0,
|
||||
width: 300,
|
||||
height: 200,
|
||||
dragHandle: undefined,
|
||||
parentId: undefined,
|
||||
sourcePosition: undefined,
|
||||
targetPosition: undefined,
|
||||
} as unknown as NodeProps<Node<NodeData>>
|
||||
return render(
|
||||
<ReactFlowProvider>
|
||||
<GroupNode {...props} />
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('GroupNode', () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ nodes: [], hideIp: false })
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
|
||||
it('renders label', () => {
|
||||
const { getByText } = renderNode({ label: 'My Group' })
|
||||
expect(getByText('My Group')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders 4 source handles (one per side)', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('.react-flow__handle-top.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-right.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-bottom.source')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-left.source')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders 4 target handles (one per side)', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('.react-flow__handle-top.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-right.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-bottom.target')).not.toBeNull()
|
||||
expect(container.querySelector('.react-flow__handle-left.target')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('source handles carry side-specific ids', () => {
|
||||
const { container } = renderNode()
|
||||
expect(container.querySelector('[data-handleid="group-top"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-right"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-bottom"]')).not.toBeNull()
|
||||
expect(container.querySelector('[data-handleid="group-left"]')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
/**
|
||||
* Type-level assertions for the collapse feature. Behavioral coverage lives
|
||||
* in:
|
||||
* - src/stores/__tests__/canvasStore.collapse.test.ts (store action)
|
||||
* - src/utils/__tests__/collapseFilter.test.ts (BFS + edge rewire)
|
||||
* - src/utils/__tests__/canvasSerializer.collapse.test.ts (round-trip)
|
||||
*/
|
||||
describe('NodeData.collapsed', () => {
|
||||
it('accepts a boolean collapsed flag as a first-class field', () => {
|
||||
const nodeData: NodeData = {
|
||||
label: 'Test Zone',
|
||||
type: 'groupRect',
|
||||
status: 'online',
|
||||
services: [],
|
||||
collapsed: true,
|
||||
}
|
||||
expect(nodeData.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('treats a missing flag as expanded', () => {
|
||||
const nodeData: NodeData = {
|
||||
label: 'Test Zone',
|
||||
type: 'groupRect',
|
||||
status: 'online',
|
||||
services: [],
|
||||
}
|
||||
expect(nodeData.collapsed).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -102,6 +102,18 @@ describe('ProxmoxGroupNode', () => {
|
||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('container mode renders bottom_handles snap points', () => {
|
||||
const { container } = renderNode({ bottom_handles: 4 })
|
||||
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||
expect(sourceHandles.length).toBe(4)
|
||||
})
|
||||
|
||||
it('container mode default has single bottom handle', () => {
|
||||
const { container } = renderNode({})
|
||||
const sourceHandles = container.querySelectorAll('.react-flow__handle-bottom.source')
|
||||
expect(sourceHandles.length).toBe(1)
|
||||
})
|
||||
|
||||
it('renders cluster handles in both modes', () => {
|
||||
const { container: groupC } = renderNode({})
|
||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { type NodeProps, type Node } from '@xyflow/react'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||
} from 'lucide-react'
|
||||
import { BaseNode } from './BaseNode'
|
||||
import type { NodeData } from '@/types'
|
||||
@@ -22,6 +23,8 @@ export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
|
||||
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
||||
export const LaptopNode = (props: N) => <BaseNode {...props} icon={Laptop} />
|
||||
export const MobileNode = (props: N) => <BaseNode {...props} icon={Smartphone} />
|
||||
export const CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||
@@ -30,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, 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'
|
||||
@@ -19,6 +27,8 @@ export const nodeTypes = {
|
||||
camera: CameraNode,
|
||||
printer: PrinterNode,
|
||||
computer: ComputerNode,
|
||||
laptop: LaptopNode,
|
||||
mobile: MobileNode,
|
||||
cpl: CplNode,
|
||||
docker_host: DockerHostNode,
|
||||
docker_container: DockerContainerNode,
|
||||
@@ -29,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,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||
Cpu, Wifi, Camera, Printer, Monitor, PlugZap, Anchor, Package, Circle, Flame,
|
||||
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
||||
Radio, Zap, Lightbulb,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
@@ -21,17 +21,17 @@ import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||
|
||||
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||
'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host',
|
||||
'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host',
|
||||
'docker_container', 'zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice',
|
||||
'generic',
|
||||
]
|
||||
|
||||
const EDITABLE_EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
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,
|
||||
vm: Box, lxc: Container, nas: HardDrive, iot: Cpu, ap: Wifi,
|
||||
camera: Camera, printer: Printer, computer: Monitor, cpl: PlugZap,
|
||||
camera: Camera, 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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,13 +11,16 @@ import { resolveNodeColors } from '@/utils/nodeColors'
|
||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||
import { BrandIconPicker } from './BrandIconPicker'
|
||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||
|
||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
||||
{ 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'] },
|
||||
]
|
||||
|
||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||
@@ -48,17 +51,25 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
||||
custom_icon: undefined,
|
||||
}
|
||||
|
||||
interface ParentCandidate {
|
||||
id: string
|
||||
label: string
|
||||
type: NodeType
|
||||
}
|
||||
|
||||
interface NodeModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: Partial<NodeData>) => void
|
||||
initial?: Partial<NodeData>
|
||||
title?: string
|
||||
parentCandidates?: ParentCandidate[]
|
||||
currentNodeId?: string
|
||||
}
|
||||
|
||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||
// initial value is enough - no need for a reset effect.
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node' }: NodeModalProps) {
|
||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
||||
const merged = { ...DEFAULT_DATA, ...initial }
|
||||
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
@@ -86,8 +97,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
setLabelError(false)
|
||||
const selectedType = (form.type ?? 'generic') as NodeType
|
||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
||||
const validParentTypes = getValidParentTypes(selectedType)
|
||||
let safeParentId = form.parent_id
|
||||
if (validParentTypes.length === 0) {
|
||||
safeParentId = undefined
|
||||
} else if (safeParentId) {
|
||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||
if (!parent || !validParentTypes.includes(parent.type)) safeParentId = undefined
|
||||
}
|
||||
onSubmit({
|
||||
...form,
|
||||
parent_id: safeParentId,
|
||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||
})
|
||||
onClose()
|
||||
@@ -107,7 +127,12 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||
<Select value={form.type} onValueChange={(v) => {
|
||||
const t = v as NodeType
|
||||
setForm((f) => ({ ...f, type: t, ...(ZIGBEE_TYPES.includes(t) ? { check_method: 'none' as CheckMethod } : {}) }))
|
||||
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
|
||||
return next
|
||||
})
|
||||
}}>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Node type selector">
|
||||
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||
@@ -320,6 +345,40 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent Container */}
|
||||
{(() => {
|
||||
const childType = (form.type ?? 'generic') as NodeType
|
||||
const validParentTypes = getValidParentTypes(childType)
|
||||
if (validParentTypes.length === 0) return null
|
||||
const validParents = parentCandidates.filter(
|
||||
(n) => n.id !== currentNodeId && validParentTypes.includes(n.type),
|
||||
)
|
||||
if (validParents.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<Label className="text-xs text-muted-foreground">Parent Container</Label>
|
||||
<Select
|
||||
value={form.parent_id ?? 'none'}
|
||||
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
|
||||
>
|
||||
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Parent container selector">
|
||||
<SelectValue>
|
||||
{form.parent_id
|
||||
? (validParents.find((n) => n.id === form.parent_id)?.label ?? 'None')
|
||||
: 'None'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||
<SelectItem value="none" className="text-sm">None</SelectItem>
|
||||
{validParents.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Container mode */}
|
||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||
<div className="flex items-center justify-between col-span-2 py-1">
|
||||
@@ -455,6 +514,27 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
||||
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!form.show_port_numbers}
|
||||
onClick={() => set('show_port_numbers', !form.show_port_numbers)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||
tabIndex={0}
|
||||
aria-label="Toggle port numbers"
|
||||
style={{ background: form.show_port_numbers ? '#ff6e00' : '#30363d' }}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
||||
style={{ left: form.show_port_numbers ? 'calc(100% - 18px)' : '2px' }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,13 +3,14 @@ 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'
|
||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||
import type { NodeType, ServiceInfo } from '@/types'
|
||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||
import { buildMacProperty } from '@/utils/macProperty'
|
||||
|
||||
interface PendingDevicesModalProps {
|
||||
open: boolean
|
||||
@@ -255,11 +256,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
const fallbackLabel = deviceLabel(device)
|
||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||
const zigbee = isZigbeeType(type)
|
||||
const properties = zigbee ? buildZigbeeProperties(device) : []
|
||||
const properties = zigbee ? buildZigbeeProperties(device) : buildMacProperty(device.mac)
|
||||
const nodeData = {
|
||||
label: fallbackLabel,
|
||||
type,
|
||||
ip: device.ip ?? undefined,
|
||||
mac: device.mac ?? undefined,
|
||||
hostname: device.hostname ?? undefined,
|
||||
status: zigbee ? 'online' : 'unknown',
|
||||
services: (device.services ?? []) as ServiceInfo[],
|
||||
@@ -325,10 +327,11 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
||||
label: deviceLabel(d),
|
||||
type,
|
||||
ip: d.ip ?? undefined,
|
||||
mac: d.mac ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
properties: zigbee ? buildZigbeeProperties(d) : [],
|
||||
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -370,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 (
|
||||
<>
|
||||
@@ -405,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,152 @@
|
||||
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 [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))
|
||||
.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 })
|
||||
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>
|
||||
</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,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()
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,13 @@ describe('EdgeModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].label).toBeUndefined()
|
||||
})
|
||||
|
||||
it('round-trips the fibre type through submit (issue #21)', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'fibre' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].type).toBe('fibre')
|
||||
})
|
||||
|
||||
// ── VLAN ID field ─────────────────────────────────────────────────────────
|
||||
|
||||
it('does not show VLAN ID field for ethernet type', () => {
|
||||
|
||||
@@ -311,18 +311,48 @@ describe('NodeModal', () => {
|
||||
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||
})
|
||||
|
||||
// ── Parent Container selector removed ────────────────────────────────
|
||||
// ── Parent Container selector ─────────────────────────────────────────
|
||||
|
||||
it('does not render the Parent Container selector', () => {
|
||||
renderModal({ initial: BASE })
|
||||
it('does not render Parent Container for non-child types', () => {
|
||||
renderModal({
|
||||
initial: BASE,
|
||||
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render Parent Container for docker_container either', () => {
|
||||
renderModal({ initial: { ...BASE, type: 'docker_container' } })
|
||||
it('does not render Parent Container when no valid candidates exist', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [],
|
||||
})
|
||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Parent Container for docker_container when docker_host candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [{ id: 'dh1', label: 'Docker Host', type: 'docker_host' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'docker_container' },
|
||||
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders Parent Container for lxc when proxmox candidate exists', () => {
|
||||
renderModal({
|
||||
initial: { ...BASE, type: 'lxc' },
|
||||
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
|
||||
})
|
||||
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Appearance ────────────────────────────────────────────────────────
|
||||
|
||||
it('renders 3 color swatch labels (border, background, icon)', () => {
|
||||
@@ -386,20 +416,30 @@ describe('NodeModal', () => {
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||
})
|
||||
|
||||
it('supports the full 1..48 range', () => {
|
||||
it('supports the full 1..64 range (issue #20)', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.min).toBe('1')
|
||||
expect(slider.max).toBe('48')
|
||||
fireEvent.change(slider, { target: { value: '48' } })
|
||||
expect(slider.max).toBe('64')
|
||||
fireEvent.change(slider, { target: { value: '52' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(48)
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
||||
})
|
||||
|
||||
it('clamps pre-filled out-of-range values into [1,48]', () => {
|
||||
it('clamps pre-filled out-of-range values into [1,64]', () => {
|
||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||
expect(slider.value).toBe('48')
|
||||
expect(slider.value).toBe('64')
|
||||
})
|
||||
|
||||
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
||||
const { onSubmit } = renderModal({ initial: BASE })
|
||||
const toggle = screen.getByLabelText('Toggle port numbers')
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).show_port_numbers).toBe(true)
|
||||
})
|
||||
|
||||
// ── Zigbee nodes ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -13,6 +13,7 @@ const mockApprove = vi.fn()
|
||||
const mockHide = vi.fn()
|
||||
const mockPending = vi.fn()
|
||||
const mockHidden = vi.fn()
|
||||
const mockAddNode = vi.fn()
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
scanApi: {
|
||||
@@ -69,7 +70,7 @@ const DEVICE_ZIGBEE = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(useCanvasStore).mockReturnValue({
|
||||
addNode: vi.fn(),
|
||||
addNode: mockAddNode,
|
||||
scanEventTs: 0,
|
||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||
// setState is used by injectAutoEdges
|
||||
@@ -98,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())
|
||||
@@ -174,6 +183,34 @@ describe('PendingDevicesModal', () => {
|
||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||
})
|
||||
|
||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', 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.click(screen.getByTestId('pending-card-dev-b'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||
await waitFor(() => expect(mockAddNode).toHaveBeenCalledTimes(2))
|
||||
|
||||
// dev-a is an IP device with a MAC → node carries mac + a MAC property row.
|
||||
const ipNode = mockAddNode.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((n) => n.id === 'n1')
|
||||
expect(ipNode.data.mac).toBe('aa:bb:cc:dd:ee:01')
|
||||
expect(ipNode.data.properties).toContainEqual({
|
||||
key: 'MAC',
|
||||
value: 'aa:bb:cc:dd:ee:01',
|
||||
icon: null,
|
||||
visible: false,
|
||||
})
|
||||
|
||||
// dev-b is zigbee with no MAC → no MAC property row.
|
||||
const zbNode = mockAddNode.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((n) => n.id === 'n2')
|
||||
expect(zbNode.data.properties.some((p: { key: string }) => p.key === 'MAC')).toBe(false)
|
||||
})
|
||||
|
||||
it('bulk hide calls API with selected ids', async () => {
|
||||
render(<PendingDevicesModal {...baseProps} />)
|
||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||
@@ -213,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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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 } } as never)
|
||||
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60 } } 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 })
|
||||
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('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()
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ 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 { getServiceUrl } from '@/utils/serviceUrl'
|
||||
import { primaryIp } from '@/utils/maskIp'
|
||||
import { splitIps } from '@/utils/maskIp'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||
import type { Node } from '@xyflow/react'
|
||||
|
||||
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
const { data } = node
|
||||
const services = data.services ?? []
|
||||
const statusColor = STATUS_COLORS[data.status]
|
||||
const host = data.ip ?? data.hostname
|
||||
const ipAddresses = data.ip ? splitIps(data.ip) : []
|
||||
const host = ipAddresses[0] ?? data.hostname
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm(`Delete "${data.label}"?`)) {
|
||||
@@ -223,12 +224,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{data.ip && (
|
||||
<div className="flex justify-between gap-2 items-baseline">
|
||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
||||
<a href={`http://${primaryIp(data.ip)}`} target="_blank" rel="noopener noreferrer" className="text-xs font-mono text-[#00d4ff] hover:underline truncate flex items-center gap-1" title={data.ip}>
|
||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
{ipAddresses.length > 0 && (
|
||||
<div className="flex justify-between gap-2 items-start">
|
||||
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : 'IP Address'}</span>
|
||||
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||
{ipAddresses.map((ip, index) => (
|
||||
<span key={`${ip}-${index}`} className="inline-flex items-center shrink-0 whitespace-nowrap">
|
||||
<a
|
||||
href={`http://${ip}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs font-mono text-[#00d4ff] hover:underline inline-flex items-center gap-1"
|
||||
title={ip}
|
||||
>
|
||||
{ip}
|
||||
<ExternalLink size={10} className="shrink-0" />
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type } from 'lucide-react'
|
||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Logo } from '@/components/ui/Logo'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { scanApi, settingsApi } from '@/api/client'
|
||||
import { designsApi, scanApi } 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'
|
||||
type SidebarView = 'canvas' | 'history'
|
||||
|
||||
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||
@@ -41,15 +39,47 @@ interface SidebarProps {
|
||||
onScan: () => void
|
||||
onZigbeeImport: () => void
|
||||
onSave: () => void
|
||||
onOpenSettings: () => void
|
||||
forceView?: SidebarView
|
||||
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, forceView, 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)
|
||||
|
||||
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
||||
if (!designModal) return
|
||||
try {
|
||||
if (designModal.mode === 'create') {
|
||||
const res = await designsApi.create({ name: data.name, icon: data.icon })
|
||||
addDesign(res.data)
|
||||
} else if (designModal.design) {
|
||||
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })
|
||||
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon })
|
||||
}
|
||||
setDesignModal(null)
|
||||
} catch {
|
||||
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
|
||||
}
|
||||
}, [designModal, addDesign, updateDesign])
|
||||
|
||||
const handleDesignDelete = useCallback(async (d: Design) => {
|
||||
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
|
||||
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
|
||||
try {
|
||||
await designsApi.delete(d.id)
|
||||
removeDesign(d.id)
|
||||
toast.success('Canvas deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete canvas')
|
||||
}
|
||||
}, [designs.length, removeDesign])
|
||||
|
||||
// forceView acts as a one-shot trigger from parent; user clicks afterwards still control view.
|
||||
if (forceView !== prevForceView) {
|
||||
@@ -60,7 +90,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
}
|
||||
}
|
||||
|
||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||
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,6 +118,75 @@ 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
|
||||
@@ -121,7 +220,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{!collapsed && activeView !== 'canvas' && (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||
{activeView === 'history' && <ScanHistoryPanel />}
|
||||
{activeView === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -155,30 +253,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,6 +278,18 @@ 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>
|
||||
)
|
||||
}
|
||||
@@ -324,105 +424,6 @@ function ScanHistoryPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
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]" />
|
||||
|
||||
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
|
||||
it('displays full comma-separated IP string as link text', () => {
|
||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByText(/192\.168\.1\.10, 192\.168\.1\.11/)).toBeDefined()
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined()
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined()
|
||||
expect(screen.queryByText(',')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders separate links for semicolon-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10; 192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||
})
|
||||
|
||||
it('renders separate links for newline-separated IPs', () => {
|
||||
setupStore({ ip: '192.168.1.10\n192.168.1.11' })
|
||||
render(<DetailPanel onEdit={vi.fn()} />)
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10')
|
||||
expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
@@ -73,6 +70,7 @@ const defaultProps = {
|
||||
onScan: vi.fn(),
|
||||
onZigbeeImport: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
onOpenPending: vi.fn(),
|
||||
}
|
||||
|
||||
@@ -190,16 +188,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 ──────────────────────────────────────────────────
|
||||
@@ -257,14 +258,10 @@ describe('Sidebar', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,25 @@
|
||||
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. */
|
||||
.react-flow__edgeupdater {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Homelable dark theme — always dark */
|
||||
:root {
|
||||
--background: #0d1117;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCanvasStore } from '../canvasStore'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
describe('canvasStore - toggleNodeCollapsed', () => {
|
||||
it('toggles collapsed state on a zone node', () => {
|
||||
const { result } = renderHook(() => useCanvasStore())
|
||||
|
||||
const node: Node<NodeData> = {
|
||||
id: 'zone-1',
|
||||
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addNode(node)
|
||||
})
|
||||
|
||||
let zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.collapsed).toBeUndefined()
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.collapsed).toBe(true)
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
zone = result.current.nodes.find((n) => n.id === 'zone-1')
|
||||
expect(zone?.data.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('marks canvas as unsaved when toggling collapse', () => {
|
||||
const { result } = renderHook(() => useCanvasStore())
|
||||
|
||||
const node: Node<NodeData> = {
|
||||
id: 'zone-1',
|
||||
data: { label: 'Test Zone', type: 'groupRect', status: 'online', services: [] },
|
||||
position: { x: 0, y: 0 },
|
||||
}
|
||||
|
||||
act(() => {
|
||||
result.current.addNode(node)
|
||||
result.current.markSaved()
|
||||
})
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(false)
|
||||
|
||||
act(() => {
|
||||
result.current.toggleNodeCollapsed('zone-1')
|
||||
})
|
||||
|
||||
expect(result.current.hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ describe('canvasStore', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -497,6 +497,31 @@ describe('canvasStore', () => {
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('reconnectEdge swaps source/target and normalizes handles', () => {
|
||||
useCanvasStore.setState((s) => ({
|
||||
edges: [...s.edges, { ...makeEdge('e1', 'n1', 'n2'), sourceHandle: 'bottom', targetHandle: 'top' }],
|
||||
}))
|
||||
useCanvasStore.getState().markSaved()
|
||||
useCanvasStore.getState().reconnectEdge('e1', {
|
||||
source: 'n1',
|
||||
target: 'n3',
|
||||
sourceHandle: 'bottom-2-t',
|
||||
targetHandle: 'top-t',
|
||||
})
|
||||
const edge = useCanvasStore.getState().edges.find((e) => e.id === 'e1')
|
||||
expect(edge?.target).toBe('n3')
|
||||
expect(edge?.source).toBe('n1')
|
||||
expect(edge?.sourceHandle).toBe('bottom-2')
|
||||
expect(edge?.targetHandle).toBe('top')
|
||||
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
|
||||
})
|
||||
|
||||
it('reconnectEdge snapshots history for undo', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2')], past: [] }))
|
||||
useCanvasStore.getState().reconnectEdge('e1', { source: 'n1', target: 'n3', sourceHandle: null, targetHandle: null })
|
||||
expect(useCanvasStore.getState().past.length).toBe(1)
|
||||
})
|
||||
|
||||
it('deleteEdge removes the edge and marks unsaved', () => {
|
||||
useCanvasStore.setState((s) => ({ edges: [...s.edges, makeEdge('e1', 'n1', 'n2'), makeEdge('e2', 'n2', 'n3')] }))
|
||||
useCanvasStore.getState().markSaved()
|
||||
@@ -665,29 +690,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', () => {
|
||||
@@ -842,7 +974,7 @@ describe('canvasStore — custom style apply', () => {
|
||||
editingTextId: null,
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,13 @@ import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeSty
|
||||
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
|
||||
|
||||
interface CanvasState {
|
||||
nodes: Node<NodeData>[]
|
||||
@@ -31,10 +36,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
|
||||
@@ -44,6 +51,7 @@ interface CanvasState {
|
||||
updateNode: (id: string, data: Partial<NodeData>) => void
|
||||
deleteNode: (id: string) => void
|
||||
updateEdge: (id: string, data: Partial<EdgeData>) => void
|
||||
reconnectEdge: (id: string, connection: Connection) => void
|
||||
deleteEdge: (id: string) => void
|
||||
setProxmoxContainerMode: (proxmoxId: string, enabled: boolean) => void
|
||||
setNodeZIndex: (id: string, zIndex: number) => void
|
||||
@@ -51,6 +59,7 @@ interface CanvasState {
|
||||
setEditingGroupRectId: (id: string | null) => void
|
||||
editingTextId: string | null
|
||||
setEditingTextId: (id: string | null) => void
|
||||
toggleNodeCollapsed: (id: string) => void
|
||||
createGroup: (nodeIds: string[], name: string) => void
|
||||
ungroup: (groupId: string) => void
|
||||
markSaved: () => void
|
||||
@@ -61,6 +70,7 @@ interface CanvasState {
|
||||
notifyScanDeviceFound: () => 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
|
||||
@@ -74,13 +84,13 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
selectedNodeIds: [],
|
||||
editingGroupRectId: null,
|
||||
editingTextId: null,
|
||||
hideIp: false,
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
fitViewPending: false,
|
||||
|
||||
past: [],
|
||||
future: [],
|
||||
clipboard: [],
|
||||
clipboard: { nodes: [], edges: [] },
|
||||
|
||||
snapshotHistory: () =>
|
||||
set((state) => ({
|
||||
@@ -115,24 +125,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,
|
||||
@@ -292,6 +378,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
reconnectEdge: (id, connection) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.map((e) =>
|
||||
e.id === id
|
||||
? {
|
||||
...e,
|
||||
source: connection.source ?? e.source,
|
||||
target: connection.target ?? e.target,
|
||||
sourceHandle: normalizeHandle(connection.sourceHandle),
|
||||
targetHandle: normalizeHandle(connection.targetHandle),
|
||||
}
|
||||
: e
|
||||
),
|
||||
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
|
||||
future: [],
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
deleteEdge: (id) =>
|
||||
set((state) => ({
|
||||
edges: state.edges.filter((e) => e.id !== id),
|
||||
@@ -355,6 +459,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
setEditingTextId: (id) => set({ editingTextId: id }),
|
||||
|
||||
toggleNodeCollapsed: (id) =>
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((n) =>
|
||||
n.id === id
|
||||
? { ...n, data: { ...n.data, collapsed: !n.data.collapsed } }
|
||||
: n
|
||||
),
|
||||
hasUnsavedChanges: true,
|
||||
})),
|
||||
|
||||
createGroup: (nodeIds, name) =>
|
||||
set((state) => {
|
||||
const PADDING_H = 24
|
||||
@@ -467,13 +581,24 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
|
||||
notifyScanDeviceFound: () => set({ scanEventTs: Date.now() }),
|
||||
|
||||
toggleHideIp: () => set((s) => ({ hideIp: !s.hideIp })),
|
||||
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,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
@@ -4,7 +4,7 @@ import type { CheckMethod } from '@/types'
|
||||
|
||||
describe('NODE_TYPE_LABELS', () => {
|
||||
it('has an entry for every node type', () => {
|
||||
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'generic']
|
||||
const expectedTypes = ['isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas', 'iot', 'ap', 'camera', 'laptop', 'mobile', 'generic']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(NODE_TYPE_LABELS).toHaveProperty(t)
|
||||
expect(typeof NODE_TYPE_LABELS[t as keyof typeof NODE_TYPE_LABELS]).toBe('string')
|
||||
@@ -28,7 +28,7 @@ describe('STATUS_COLORS', () => {
|
||||
|
||||
describe('EDGE_TYPE_LABELS', () => {
|
||||
it('has an entry for every edge type', () => {
|
||||
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const expectedTypes = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||
expectedTypes.forEach((t) => {
|
||||
expect(EDGE_TYPE_LABELS).toHaveProperty(t)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
@@ -13,6 +25,8 @@ export type NodeType =
|
||||
| 'camera'
|
||||
| 'printer'
|
||||
| 'computer'
|
||||
| 'laptop'
|
||||
| 'mobile'
|
||||
| 'cpl'
|
||||
| 'docker_host'
|
||||
| 'docker_container'
|
||||
@@ -23,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'
|
||||
@@ -35,7 +63,7 @@ export type TextPosition =
|
||||
| 'bottom-center'
|
||||
| 'bottom-right'
|
||||
|
||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster'
|
||||
export type EdgeType = 'ethernet' | 'wifi' | 'iot' | 'vlan' | 'virtual' | 'cluster' | 'fibre' | 'electrical'
|
||||
|
||||
export type NodeStatus = 'online' | 'offline' | 'pending' | 'unknown'
|
||||
|
||||
@@ -97,9 +125,17 @@ export interface NodeData extends Record<string, unknown> {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
/**
|
||||
* Collapsible zone state (type === 'groupRect'). When true, the zone hides
|
||||
* its descendants on the canvas. Persisted via `custom_colors.collapsed`
|
||||
* round-trip for back-compat with older saves.
|
||||
*/
|
||||
collapsed?: boolean
|
||||
custom_icon?: string
|
||||
/** Number of bottom connection points, 1..48. Default 1 (centered). */
|
||||
/** Number of bottom connection points, 1..64. Default 1 (centered). */
|
||||
bottom_handles?: number
|
||||
/** Show a port number (1..N) above each bottom connection point. */
|
||||
show_port_numbers?: boolean
|
||||
/** Text node content (type === 'text') */
|
||||
text_content?: string
|
||||
}
|
||||
@@ -137,6 +173,8 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
camera: 'Camera',
|
||||
printer: 'Printer',
|
||||
computer: 'Computer',
|
||||
laptop: 'Laptop',
|
||||
mobile: 'Phone / Mobile',
|
||||
cpl: 'CPL / Powerline',
|
||||
docker_host: 'Docker Host',
|
||||
docker_container: 'Docker Container',
|
||||
@@ -147,6 +185,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> = {
|
||||
@@ -163,6 +215,8 @@ export const EDGE_TYPE_LABELS: Record<EdgeType, string> = {
|
||||
vlan: 'VLAN',
|
||||
virtual: 'Virtual',
|
||||
cluster: 'Cluster',
|
||||
fibre: 'Fibre',
|
||||
electrical: 'Electrical Wire',
|
||||
}
|
||||
|
||||
export interface NodeTypeStyle {
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
import { serializeNode, deserializeApiNode, type ApiNode } from '@/utils/canvasSerializer'
|
||||
|
||||
/**
|
||||
* Persistence contract for the collapse flag on groupRect nodes:
|
||||
*
|
||||
* 1. Serialize stashes `data.collapsed` into `custom_colors.collapsed`
|
||||
* so the existing API blob shape can carry it without a schema change.
|
||||
* 2. Deserialize hoists it back to the first-class `data.collapsed` field.
|
||||
* 3. Legacy saves that already had `custom_colors.collapsed` (the original
|
||||
* shape from PR #158 before the field was promoted) still load
|
||||
* correctly.
|
||||
*/
|
||||
|
||||
function makeGroupRectRfNode(collapsed?: boolean): Node<NodeData> {
|
||||
return {
|
||||
id: 'zone-1',
|
||||
type: 'groupRect',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Zigbee Mesh',
|
||||
type: 'groupRect',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
...(collapsed !== undefined ? { collapsed } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('canvasSerializer — groupRect collapse', () => {
|
||||
it('stashes data.collapsed=true into custom_colors on serialize', () => {
|
||||
const rf = makeGroupRectRfNode(true)
|
||||
const api = serializeNode(rf) as Record<string, unknown>
|
||||
const cc = api.custom_colors as Record<string, unknown>
|
||||
expect(cc.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('writes collapsed=false when the flag is missing (explicit default)', () => {
|
||||
const rf = makeGroupRectRfNode(undefined)
|
||||
const api = serializeNode(rf) as Record<string, unknown>
|
||||
const cc = api.custom_colors as Record<string, unknown>
|
||||
expect(cc.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('hoists custom_colors.collapsed back to data.collapsed on deserialize', () => {
|
||||
const apiNode: ApiNode = {
|
||||
id: 'zone-1',
|
||||
type: 'groupRect',
|
||||
label: 'Zone',
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { collapsed: true, width: 360, height: 240 },
|
||||
}
|
||||
const rf = deserializeApiNode(apiNode, new Map())
|
||||
expect(rf.data.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('treats missing custom_colors.collapsed as false on deserialize', () => {
|
||||
const apiNode: ApiNode = {
|
||||
id: 'zone-1',
|
||||
type: 'groupRect',
|
||||
label: 'Zone',
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { width: 360, height: 240 },
|
||||
}
|
||||
const rf = deserializeApiNode(apiNode, new Map())
|
||||
expect(rf.data.collapsed).toBe(false)
|
||||
})
|
||||
|
||||
it('round-trips the collapse flag through serialize → deserialize', () => {
|
||||
const rf = makeGroupRectRfNode(true)
|
||||
const api = serializeNode(rf) as unknown as ApiNode
|
||||
const back = deserializeApiNode(api, new Map())
|
||||
expect(back.data.collapsed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('canvasSerializer — collapse on non-groupRect node types', () => {
|
||||
it('stashes data.collapsed into custom_colors for a group container', () => {
|
||||
const rf: Node<NodeData> = {
|
||||
id: 'g1',
|
||||
type: 'group',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Container',
|
||||
type: 'group',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { show_border: true },
|
||||
collapsed: true,
|
||||
},
|
||||
}
|
||||
const api = serializeNode(rf) as Record<string, unknown>
|
||||
const cc = api.custom_colors as Record<string, unknown>
|
||||
expect(cc.collapsed).toBe(true)
|
||||
// Existing custom_colors keys are preserved alongside the stash.
|
||||
expect(cc.show_border).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves custom_colors null when neither flag nor colors are set', () => {
|
||||
const rf: Node<NodeData> = {
|
||||
id: 's1',
|
||||
type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: 'Server', type: 'server', status: 'online', services: [] },
|
||||
}
|
||||
const api = serializeNode(rf) as Record<string, unknown>
|
||||
expect(api.custom_colors).toBeNull()
|
||||
})
|
||||
|
||||
it('hoists custom_colors.collapsed to data.collapsed for a group container', () => {
|
||||
const apiNode: ApiNode = {
|
||||
id: 'g1',
|
||||
type: 'group',
|
||||
label: 'Container',
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { show_border: true, collapsed: true },
|
||||
}
|
||||
const rf = deserializeApiNode(apiNode, new Map())
|
||||
expect(rf.data.collapsed).toBe(true)
|
||||
})
|
||||
|
||||
it('round-trips collapse on a group container', () => {
|
||||
const rf: Node<NodeData> = {
|
||||
id: 'g1',
|
||||
type: 'group',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
label: 'Container',
|
||||
type: 'group',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
custom_colors: { show_border: true },
|
||||
collapsed: true,
|
||||
},
|
||||
}
|
||||
const api = serializeNode(rf) as unknown as ApiNode
|
||||
const back = deserializeApiNode(api, new Map())
|
||||
expect(back.data.collapsed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
import {
|
||||
getVisibleNodeIds,
|
||||
rewireEdgesForCollapse,
|
||||
getZoneSpatialChildren,
|
||||
computeCollapseInfo,
|
||||
} from '../collapseFilter'
|
||||
import type { EdgeData, NodeData } from '@/types'
|
||||
|
||||
interface MkOpts {
|
||||
parentId?: string
|
||||
collapsed?: boolean
|
||||
position?: { x: number; y: number }
|
||||
width?: number
|
||||
height?: number
|
||||
type?: NodeData['type']
|
||||
}
|
||||
|
||||
// Outside the default 360x240 zone bbox at origin — used by tests that need
|
||||
// a node that must NOT be spatially captured by a collapsed zone.
|
||||
const FAR = { x: 10000, y: 0 }
|
||||
|
||||
const mkNode = (id: string, opts: MkOpts = {}): Node<NodeData> => ({
|
||||
id,
|
||||
position: opts.position ?? { x: 0, y: 0 },
|
||||
...(opts.width !== undefined ? { width: opts.width } : {}),
|
||||
...(opts.height !== undefined ? { height: opts.height } : {}),
|
||||
...(opts.parentId ? { parentId: opts.parentId } : {}),
|
||||
data: {
|
||||
label: id,
|
||||
type: opts.type ?? (opts.parentId ? 'server' : 'groupRect'),
|
||||
status: 'online',
|
||||
services: [],
|
||||
...(opts.collapsed !== undefined ? { collapsed: opts.collapsed } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const mkEdge = (id: string, source: string, target: string): Edge<EdgeData> => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
})
|
||||
|
||||
describe('getVisibleNodeIds — parentId cascade', () => {
|
||||
it('returns all nodes when nothing is collapsed', () => {
|
||||
const nodes = [
|
||||
mkNode('zone'),
|
||||
mkNode('child-a', { parentId: 'zone' }),
|
||||
mkNode('child-b', { parentId: 'zone' }),
|
||||
]
|
||||
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child-a', 'child-b']))
|
||||
})
|
||||
|
||||
it('hides direct children of a collapsed parent but keeps the parent itself', () => {
|
||||
const nodes = [
|
||||
mkNode('zone', { collapsed: true }),
|
||||
mkNode('child-a', { parentId: 'zone' }),
|
||||
mkNode('child-b', { parentId: 'zone' }),
|
||||
mkNode('outside', { position: FAR }),
|
||||
]
|
||||
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'outside']))
|
||||
})
|
||||
|
||||
it('hides the entire subtree when an ancestor is collapsed (multi-level)', () => {
|
||||
const nodes = [
|
||||
mkNode('root', { collapsed: true }),
|
||||
mkNode('mid', { parentId: 'root', collapsed: false }),
|
||||
mkNode('leaf', { parentId: 'mid' }),
|
||||
]
|
||||
const v = getVisibleNodeIds(nodes)
|
||||
expect(v.has('root')).toBe(true)
|
||||
expect(v.has('mid')).toBe(false)
|
||||
expect(v.has('leaf')).toBe(false)
|
||||
})
|
||||
|
||||
it('hides only the nested subtree when an inner zone is collapsed', () => {
|
||||
const nodes = [
|
||||
mkNode('root', { collapsed: false }),
|
||||
mkNode('inner', { parentId: 'root', collapsed: true }),
|
||||
mkNode('leaf', { parentId: 'inner' }),
|
||||
mkNode('sibling', { parentId: 'root' }),
|
||||
]
|
||||
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['root', 'inner', 'sibling']))
|
||||
})
|
||||
|
||||
it('handles a zone with no children', () => {
|
||||
expect(getVisibleNodeIds([mkNode('empty-zone', { collapsed: true })]))
|
||||
.toEqual(new Set(['empty-zone']))
|
||||
})
|
||||
|
||||
it('returns an empty set for empty input', () => {
|
||||
expect(getVisibleNodeIds([])).toEqual(new Set())
|
||||
})
|
||||
|
||||
it('treats nodes with no collapsed flag as expanded', () => {
|
||||
const nodes = [mkNode('zone'), mkNode('child', { parentId: 'zone' })]
|
||||
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone', 'child']))
|
||||
})
|
||||
|
||||
it('is independent of insertion order (children declared before parent)', () => {
|
||||
const nodes = [
|
||||
mkNode('child', { parentId: 'zone' }),
|
||||
mkNode('zone', { collapsed: true }),
|
||||
]
|
||||
expect(getVisibleNodeIds(nodes)).toEqual(new Set(['zone']))
|
||||
})
|
||||
})
|
||||
|
||||
describe('getZoneSpatialChildren', () => {
|
||||
it('picks up top-level nodes whose centre lies inside the zone bbox', () => {
|
||||
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
|
||||
const inside = mkNode('inside', { position: { x: 100, y: 50 }, type: 'server' })
|
||||
const outside = mkNode('outside', { position: { x: 500, y: 0 }, type: 'server' })
|
||||
expect(getZoneSpatialChildren(zone, [zone, inside, outside])).toEqual(['inside'])
|
||||
})
|
||||
|
||||
it('ignores the zone itself', () => {
|
||||
const zone = mkNode('zone', { width: 400, height: 300 })
|
||||
expect(getZoneSpatialChildren(zone, [zone])).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores nodes with a parentId (handled via parentId cascade)', () => {
|
||||
const zone = mkNode('zone', { width: 400, height: 300 })
|
||||
const child = mkNode('child', { parentId: 'other', type: 'server' })
|
||||
expect(getZoneSpatialChildren(zone, [zone, child])).toEqual([])
|
||||
})
|
||||
|
||||
it('uses fallback dimensions for nodes with no width/height set', () => {
|
||||
const zone = mkNode('zone', { width: 400, height: 300 })
|
||||
// No width/height → defaults (200, 80). Centre at (100, 40), inside.
|
||||
const n = mkNode('n', { type: 'server' })
|
||||
expect(getZoneSpatialChildren(zone, [zone, n])).toEqual(['n'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeCollapseInfo — spatial collapse via groupRect zones', () => {
|
||||
it('hides nodes spatially inside a collapsed zone and records hiddenBy', () => {
|
||||
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
|
||||
const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' })
|
||||
const outside = mkNode('outside', { position: FAR, type: 'server' })
|
||||
const info = computeCollapseInfo([zone, inside, outside])
|
||||
expect(info.visibleIds).toEqual(new Set(['zone', 'outside']))
|
||||
expect(info.hiddenBy.get('inside')).toBe('zone')
|
||||
})
|
||||
|
||||
it('cascades parentId descendants of spatially-hidden nodes', () => {
|
||||
// Proxmox host sitting inside a collapsed zone — its VMs (parentId)
|
||||
// must also be hidden even though they live at relative coords.
|
||||
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
|
||||
const px = mkNode('px', { position: { x: 50, y: 50 }, type: 'proxmox' })
|
||||
const vm = mkNode('vm', { parentId: 'px', type: 'vm' })
|
||||
const info = computeCollapseInfo([zone, px, vm])
|
||||
expect(info.visibleIds).toEqual(new Set(['zone']))
|
||||
expect(info.hiddenBy.get('vm')).toBe('zone')
|
||||
})
|
||||
|
||||
it('a nested groupRect inside a collapsed outer zone is also hidden', () => {
|
||||
const outer = mkNode('outer', { collapsed: true, width: 600, height: 400 })
|
||||
const inner = mkNode('inner', { position: { x: 100, y: 100 }, width: 200, height: 150 })
|
||||
const leaf = mkNode('leaf', { position: { x: 150, y: 150 }, type: 'server' })
|
||||
const info = computeCollapseInfo([outer, inner, leaf])
|
||||
expect(info.visibleIds).toEqual(new Set(['outer']))
|
||||
})
|
||||
|
||||
it('does not affect nodes outside every collapsed zone', () => {
|
||||
const a = mkNode('a', { collapsed: true, width: 300, height: 200 })
|
||||
const b = mkNode('b', { position: { x: 1000, y: 1000 }, width: 300, height: 200 })
|
||||
const free = mkNode('free', { position: { x: 2000, y: 2000 }, type: 'server' })
|
||||
const info = computeCollapseInfo([a, b, free])
|
||||
expect(info.visibleIds.has('free')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewireEdgesForCollapse', () => {
|
||||
it('keeps edges between two visible nodes unchanged (same reference)', () => {
|
||||
const nodes = [mkNode('a'), mkNode('b', { position: FAR })]
|
||||
const edges = [mkEdge('e1', 'a', 'b')]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0]).toBe(edges[0])
|
||||
})
|
||||
|
||||
it('reroutes a cross-boundary edge to the collapsed parentId ancestor', () => {
|
||||
const nodes = [
|
||||
mkNode('zone', { collapsed: true }),
|
||||
mkNode('leaf', { parentId: 'zone' }),
|
||||
mkNode('outside', { position: FAR }),
|
||||
]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
const edges = [mkEdge('e1', 'outside', 'leaf')]
|
||||
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
|
||||
expect(out[0].source).toBe('outside')
|
||||
expect(out[0].target).toBe('zone')
|
||||
expect(out[0].sourceHandle).toBeNull()
|
||||
expect(out[0].targetHandle).toBeNull()
|
||||
})
|
||||
|
||||
it('reroutes a cross-boundary edge to a collapsed groupRect zone (spatial)', () => {
|
||||
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
|
||||
const inside = mkNode('inside', { position: { x: 50, y: 50 }, type: 'server' })
|
||||
const outside = mkNode('outside', { position: FAR, type: 'server' })
|
||||
const nodes = [zone, inside, outside]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
const edges = [mkEdge('e1', 'outside', 'inside')]
|
||||
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
|
||||
expect(out[0].source).toBe('outside')
|
||||
expect(out[0].target).toBe('zone')
|
||||
})
|
||||
|
||||
it('drops an edge between two siblings inside the same collapsed zone (self-loop)', () => {
|
||||
const nodes = [
|
||||
mkNode('zone', { collapsed: true }),
|
||||
mkNode('a', { parentId: 'zone' }),
|
||||
mkNode('b', { parentId: 'zone' }),
|
||||
]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
expect(rewireEdgesForCollapse([mkEdge('e1', 'a', 'b')], nodes, info.visibleIds, info.hiddenBy))
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('de-dupes parallel cross-boundary edges that rewire to the same pair', () => {
|
||||
const nodes = [
|
||||
mkNode('zone', { collapsed: true }),
|
||||
mkNode('coord', { position: FAR }),
|
||||
...Array.from({ length: 5 }, (_, i) => mkNode(`leaf-${i}`, { parentId: 'zone' })),
|
||||
]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
const edges = Array.from({ length: 5 }, (_, i) => mkEdge(`e-${i}`, 'coord', `leaf-${i}`))
|
||||
const out = rewireEdgesForCollapse(edges, nodes, info.visibleIds, info.hiddenBy)
|
||||
expect(out).toHaveLength(1)
|
||||
expect(out[0].source).toBe('coord')
|
||||
expect(out[0].target).toBe('zone')
|
||||
})
|
||||
|
||||
it('walks the chain to the nearest visible ancestor (nested collapse)', () => {
|
||||
const nodes = [
|
||||
mkNode('root', { collapsed: true }),
|
||||
mkNode('mid', { parentId: 'root' }),
|
||||
mkNode('leaf', { parentId: 'mid' }),
|
||||
mkNode('outside', { position: FAR }),
|
||||
]
|
||||
const info = computeCollapseInfo(nodes)
|
||||
const out = rewireEdgesForCollapse(
|
||||
[mkEdge('e1', 'outside', 'leaf')],
|
||||
nodes,
|
||||
info.visibleIds,
|
||||
info.hiddenBy,
|
||||
)
|
||||
expect(out[0].target).toBe('root')
|
||||
})
|
||||
|
||||
it('drops an edge whose endpoint has no visible ancestor', () => {
|
||||
const edges = [mkEdge('e1', 'ghost', 'also-ghost')]
|
||||
expect(rewireEdgesForCollapse(edges, [], new Set(), new Map())).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array for empty input', () => {
|
||||
expect(rewireEdgesForCollapse([], [], new Set(), new Map())).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'
|
||||
import { EDGE_DEFAULT_COLORS } from '../edgeColors'
|
||||
import type { EdgeType } from '@/types'
|
||||
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||
|
||||
describe('EDGE_DEFAULT_COLORS', () => {
|
||||
it('has an entry for every EdgeType', () => {
|
||||
@@ -36,4 +36,8 @@ describe('EDGE_DEFAULT_COLORS', () => {
|
||||
it('cluster default is proxmox orange', () => {
|
||||
expect(EDGE_DEFAULT_COLORS.cluster).toBe('#ff6e00')
|
||||
})
|
||||
|
||||
it('fibre default is bright cyan', () => {
|
||||
expect(EDGE_DEFAULT_COLORS.fibre).toBe('#22d3ee')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,6 +102,15 @@ describe('exportCanvasToYaml', () => {
|
||||
expect(entryA).not.toHaveProperty('clusterR')
|
||||
})
|
||||
|
||||
it('serializes a fibre edge with linkType "fibre" (issue #21)', () => {
|
||||
const nodeA = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||
const nodeB = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||
const edge = makeEdge('e1', 'sw', 's1', { type: 'fibre', label: 'sfp0' })
|
||||
const result = yaml.load(exportCanvasToYaml([nodeA, nodeB], [edge])) as Record<string, unknown>[]
|
||||
const entryA = result.find((e) => e.label === 'Switch')!
|
||||
expect(entryA.links).toEqual([{ label: 'Server1', linkType: 'fibre', linkLabel: 'sfp0' }])
|
||||
})
|
||||
|
||||
it('serializes multiple outgoing edges as links array', () => {
|
||||
const sw = makeNode({ label: 'Switch', type: 'switch' }, 'sw')
|
||||
const s1 = makeNode({ label: 'Server1', type: 'server' }, 's1')
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useCanvasStore } from '../../stores/canvasStore'
|
||||
import { computeCollapseInfo } from '../collapseFilter'
|
||||
import type { Node } from '@xyflow/react'
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
const mk = (id: string, type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||
id,
|
||||
type,
|
||||
position: { x: 100, y: 100 },
|
||||
data: { label: id, type, status: 'online', services: [] },
|
||||
})
|
||||
|
||||
describe('integration — createGroup + toggleNodeCollapsed hides children', () => {
|
||||
it('hides parentId children of a collapsed group container', () => {
|
||||
const { result } = renderHook(() => useCanvasStore())
|
||||
act(() => {
|
||||
result.current.addNode(mk('c1'))
|
||||
result.current.addNode(mk('c2'))
|
||||
result.current.createGroup(['c1', 'c2'], 'My Group')
|
||||
})
|
||||
// Find the auto-generated group id.
|
||||
const grp = result.current.nodes.find((n) => n.type === 'group')!
|
||||
expect(grp).toBeDefined()
|
||||
expect(result.current.nodes.find((n) => n.id === 'c1')!.parentId).toBe(grp.id)
|
||||
|
||||
// Pre-collapse: all visible.
|
||||
let info = computeCollapseInfo(result.current.nodes)
|
||||
expect(info.visibleIds.has('c1')).toBe(true)
|
||||
expect(info.visibleIds.has('c2')).toBe(true)
|
||||
|
||||
// Collapse the group via the store action.
|
||||
act(() => result.current.toggleNodeCollapsed(grp.id))
|
||||
expect(result.current.nodes.find((n) => n.id === grp.id)!.data.collapsed).toBe(true)
|
||||
|
||||
info = computeCollapseInfo(result.current.nodes)
|
||||
expect(info.visibleIds.has(grp.id)).toBe(true)
|
||||
expect(info.visibleIds.has('c1')).toBe(false)
|
||||
expect(info.visibleIds.has('c2')).toBe(false)
|
||||
expect(info.hiddenBy.get('c1')).toBe(grp.id)
|
||||
})
|
||||
})
|
||||
@@ -29,8 +29,12 @@ describe('clampBottomHandles', () => {
|
||||
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
|
||||
expect(MAX_BOTTOM_HANDLES).toBeGreaterThanOrEqual(52)
|
||||
})
|
||||
|
||||
it('clamps above MAX to MAX', () => {
|
||||
expect(clampBottomHandles(49)).toBe(MAX_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(65)).toBe(MAX_BOTTOM_HANDLES)
|
||||
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
|
||||
})
|
||||
|
||||
@@ -49,6 +53,8 @@ describe('clampBottomHandles', () => {
|
||||
expect(clampBottomHandles(1)).toBe(1)
|
||||
expect(clampBottomHandles(24)).toBe(24)
|
||||
expect(clampBottomHandles(48)).toBe(48)
|
||||
expect(clampBottomHandles(52)).toBe(52)
|
||||
expect(clampBottomHandles(64)).toBe(64)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -89,6 +89,22 @@ describe('parseYamlToCanvas', () => {
|
||||
expect(edges[0].targetHandle).toBe('top-t')
|
||||
})
|
||||
|
||||
it('imports a fibre link type onto the edge (issue #21)', () => {
|
||||
const yaml = `
|
||||
- nodeType: switch
|
||||
label: "SW"
|
||||
links:
|
||||
- label: "SRV"
|
||||
linkType: fibre
|
||||
- nodeType: server
|
||||
label: "SRV"
|
||||
`
|
||||
const { edges } = parseYamlToCanvas(yaml, empty, emptyEdges)
|
||||
expect(edges).toHaveLength(1)
|
||||
expect(edges[0].type).toBe('fibre')
|
||||
expect(edges[0].data?.type).toBe('fibre')
|
||||
})
|
||||
|
||||
it('cluster edges have cluster-right→cluster-left handles', () => {
|
||||
const yaml = `
|
||||
- nodeType: proxmox
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildMacProperty } from '../macProperty'
|
||||
|
||||
describe('buildMacProperty', () => {
|
||||
it('returns a hidden MAC property row for a MAC', () => {
|
||||
expect(buildMacProperty('aa:bb:cc:dd:ee:ff')).toEqual([
|
||||
{ key: 'MAC', value: 'aa:bb:cc:dd:ee:ff', icon: null, visible: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an empty array when MAC is null/undefined/empty', () => {
|
||||
expect(buildMacProperty(null)).toEqual([])
|
||||
expect(buildMacProperty(undefined)).toEqual([])
|
||||
expect(buildMacProperty('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -55,6 +55,27 @@ describe('splitIps', () => {
|
||||
expect(splitIps('')).toEqual([])
|
||||
expect(splitIps(' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('splits on semicolons', () => {
|
||||
expect(splitIps('10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||
})
|
||||
|
||||
it('splits on newlines', () => {
|
||||
expect(splitIps('10.0.0.1\n10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||
})
|
||||
|
||||
it('splits on mixed delimiters', () => {
|
||||
expect(splitIps('10.0.0.1,10.0.0.2; 10.0.0.3\n10.0.0.4')).toEqual([
|
||||
'10.0.0.1',
|
||||
'10.0.0.2',
|
||||
'10.0.0.3',
|
||||
'10.0.0.4',
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates repeated IPs', () => {
|
||||
expect(splitIps('10.0.0.1, 10.0.0.1; 10.0.0.2')).toEqual(['10.0.0.1', '10.0.0.2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('primaryIp', () => {
|
||||
|
||||
@@ -4,9 +4,9 @@ import type { NodeType, EdgeType, NodeStatus } from '@/types'
|
||||
|
||||
const NODE_TYPES: NodeType[] = [
|
||||
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||
'nas', 'iot', 'ap', 'camera', 'printer', 'computer', 'laptop', 'mobile', 'cpl', 'docker_host', 'docker_container', 'generic', 'groupRect',
|
||||
]
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster']
|
||||
const EDGE_TYPES: EdgeType[] = ['ethernet', 'wifi', 'iot', 'vlan', 'virtual', 'cluster', 'fibre']
|
||||
const STATUS_TYPES: NodeStatus[] = ['online', 'offline', 'pending', 'unknown']
|
||||
|
||||
describe('THEME_ORDER', () => {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveVirtualEdgeParent, getValidParentTypes } from '../virtualEdgeParent'
|
||||
|
||||
describe('getValidParentTypes', () => {
|
||||
it('returns container-mode types for lxc', () => {
|
||||
expect(getValidParentTypes('lxc')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||
})
|
||||
|
||||
it('returns container-mode types for vm', () => {
|
||||
expect(getValidParentTypes('vm')).toEqual(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||
})
|
||||
|
||||
it('returns docker_host/lxc/vm/proxmox for docker_container', () => {
|
||||
expect(getValidParentTypes('docker_container')).toEqual(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||
})
|
||||
|
||||
it('returns empty list for types that cannot have a parent', () => {
|
||||
expect(getValidParentTypes('server')).toEqual([])
|
||||
expect(getValidParentTypes('router')).toEqual([])
|
||||
expect(getValidParentTypes('proxmox')).toEqual([])
|
||||
expect(getValidParentTypes('docker_host')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVirtualEdgeParent', () => {
|
||||
it('nests lxc under proxmox (container-mode parent)', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'lxc1', type: 'lxc' },
|
||||
{ id: 'px1', type: 'proxmox' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'lxc1', parentId: 'px1' })
|
||||
})
|
||||
|
||||
it('nests vm under proxmox regardless of edge direction', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'px1', type: 'proxmox' },
|
||||
{ id: 'vm1', type: 'vm' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'vm1', parentId: 'px1' })
|
||||
})
|
||||
|
||||
it('nests docker_container under docker_host', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
{ id: 'dh1', type: 'docker_host' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'dc1', parentId: 'dh1' })
|
||||
})
|
||||
|
||||
it('nests docker_container under lxc (reverse direction)', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'lxc1', type: 'lxc' },
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||
})
|
||||
|
||||
it('nests docker_container under lxc (forward direction)', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
{ id: 'lxc1', type: 'lxc' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'dc1', parentId: 'lxc1' })
|
||||
})
|
||||
|
||||
it('nests docker_container under vm', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
{ id: 'vm1', type: 'vm' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'dc1', parentId: 'vm1' })
|
||||
})
|
||||
|
||||
it('nests docker_container under proxmox (reverse direction)', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'px1', type: 'proxmox' },
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
)
|
||||
expect(res).toEqual({ childId: 'dc1', parentId: 'px1' })
|
||||
})
|
||||
|
||||
it('returns null when docker_container links to unsupported parent type', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'dc1', type: 'docker_container' },
|
||||
{ id: 'srv1', type: 'server' },
|
||||
)
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unrelated type pairs', () => {
|
||||
const res = resolveVirtualEdgeParent(
|
||||
{ id: 'srv1', type: 'server' },
|
||||
{ id: 'rt1', type: 'router' },
|
||||
)
|
||||
expect(res).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,7 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
show_port_numbers?: boolean
|
||||
}
|
||||
|
||||
export interface ApiEdge {
|
||||
@@ -76,6 +77,9 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
...n.data.custom_colors,
|
||||
width: n.measured?.width ?? n.width ?? 360,
|
||||
height: n.measured?.height ?? n.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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -94,7 +98,13 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
notes: n.data.notes ?? null,
|
||||
parent_id: n.data.parent_id ?? null,
|
||||
container_mode: n.data.container_mode ?? false,
|
||||
custom_colors: n.data.custom_colors ?? null,
|
||||
// Stash collapse state inside the custom_colors blob so the backend's
|
||||
// dict[str, Any] column carries it without a schema change. Hoisted
|
||||
// back to `data.collapsed` on load. Applies to every node type — group
|
||||
// containers, Proxmox hosts, etc. — not just groupRect zones.
|
||||
custom_colors: n.data.collapsed !== undefined
|
||||
? { ...(n.data.custom_colors ?? {}), collapsed: n.data.collapsed }
|
||||
: (n.data.custom_colors ?? null),
|
||||
custom_icon: n.data.custom_icon ?? null,
|
||||
cpu_count: n.data.cpu_count ?? null,
|
||||
cpu_model: n.data.cpu_model ?? null,
|
||||
@@ -105,6 +115,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
width: n.measured?.width ?? n.width ?? null,
|
||||
height: n.measured?.height ?? n.height ?? null,
|
||||
bottom_handles: clampBottomHandles(n.data.bottom_handles ?? 1),
|
||||
show_port_numbers: n.data.show_port_numbers ?? false,
|
||||
pos_x: n.position.x,
|
||||
pos_y: n.position.y,
|
||||
}
|
||||
@@ -139,11 +150,15 @@ export function deserializeApiNode(
|
||||
const w = (n.custom_colors?.width as number | undefined) ?? 360
|
||||
const h = (n.custom_colors?.height as number | undefined) ?? 240
|
||||
const z = (n.custom_colors?.z_order as number | undefined) ?? 1
|
||||
// Hoist persisted collapse flag from the custom_colors stash to a
|
||||
// first-class field on NodeData. Tolerates legacy saves that already had
|
||||
// it there from before the type was promoted.
|
||||
const collapsed = Boolean(n.custom_colors?.collapsed)
|
||||
return {
|
||||
id: n.id,
|
||||
type: 'groupRect',
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: n as unknown as NodeData,
|
||||
data: { ...(n as unknown as NodeData), collapsed },
|
||||
width: w,
|
||||
height: h,
|
||||
zIndex: z - 10,
|
||||
@@ -155,7 +170,14 @@ export function deserializeApiNode(
|
||||
id: n.id,
|
||||
type: normalizedType,
|
||||
position: { x: n.pos_x, y: n.pos_y },
|
||||
data: { ...n, type: normalizedType, bottom_handles: clampBottomHandles(n.bottom_handles ?? 1) } as unknown as NodeData,
|
||||
// Hoist persisted collapse flag from the custom_colors stash (matches
|
||||
// the symmetric serialize step). Applies to every node type.
|
||||
data: {
|
||||
...n,
|
||||
type: normalizedType,
|
||||
bottom_handles: clampBottomHandles(n.bottom_handles ?? 1),
|
||||
collapsed: Boolean(n.custom_colors?.collapsed),
|
||||
} as unknown as NodeData,
|
||||
...(n.parent_id && parentIsContainer ? { parentId: n.parent_id, extent: 'parent' as const } : {}),
|
||||
...(['proxmox', 'vm', 'lxc', 'docker_host'].includes(normalizedType) && n.container_mode !== false
|
||||
? { width: n.width ?? 300, height: n.height ?? 200 }
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
import type { EdgeData, NodeData } from '@/types'
|
||||
|
||||
/**
|
||||
* Collapse model
|
||||
* ──────────────
|
||||
* Two ways a node can collapse and hide what it "contains":
|
||||
*
|
||||
* 1. parentId hierarchy — `type: 'group'` containers (createGroup) and
|
||||
* Proxmox container_mode children. Setting `data.collapsed = true` on
|
||||
* such a node hides every node in its parentId subtree.
|
||||
*
|
||||
* 2. Spatial containment — `type: 'groupRect'` decorative zones drawn
|
||||
* around nodes. Zones do not parent their contents in React Flow, so
|
||||
* we hit-test every top-level node's centre against the zone bbox to
|
||||
* decide what is "inside". Collapsing a zone hides every node whose
|
||||
* centre lies inside the zone (plus the parentId subtrees of those
|
||||
* nodes, so e.g. a Proxmox host inside a collapsed zone also takes its
|
||||
* VMs/LXCs with it).
|
||||
*
|
||||
* `hiddenBy` records which collapsed ancestor hid each node — used by edge
|
||||
* rewiring to redirect a vanished endpoint to the visible zone the user is
|
||||
* actually looking at.
|
||||
*/
|
||||
|
||||
interface BBox { x: number; y: number; w: number; h: number }
|
||||
|
||||
const DEFAULT_NODE_W = 200
|
||||
const DEFAULT_NODE_H = 80
|
||||
const DEFAULT_ZONE_W = 360
|
||||
const DEFAULT_ZONE_H = 240
|
||||
|
||||
function bboxOf(n: Node<NodeData>, fallbackW: number, fallbackH: number): BBox {
|
||||
return {
|
||||
x: n.position.x,
|
||||
y: n.position.y,
|
||||
w: n.width ?? fallbackW,
|
||||
h: n.height ?? fallbackH,
|
||||
}
|
||||
}
|
||||
|
||||
function centerInside(n: Node<NodeData>, b: BBox): boolean {
|
||||
const w = n.width ?? DEFAULT_NODE_W
|
||||
const h = n.height ?? DEFAULT_NODE_H
|
||||
const cx = n.position.x + w / 2
|
||||
const cy = n.position.y + h / 2
|
||||
return cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h
|
||||
}
|
||||
|
||||
/**
|
||||
* Node ids whose centre lies inside the given zone, excluding the zone
|
||||
* itself and any node that is a React Flow child (parentId set — those are
|
||||
* positioned relative to their parent, not in absolute canvas coordinates).
|
||||
*/
|
||||
export function getZoneSpatialChildren(
|
||||
zone: Node<NodeData>,
|
||||
nodes: Node<NodeData>[],
|
||||
): string[] {
|
||||
const zb = bboxOf(zone, DEFAULT_ZONE_W, DEFAULT_ZONE_H)
|
||||
const out: string[] = []
|
||||
for (const n of nodes) {
|
||||
if (n.id === zone.id) continue
|
||||
if (n.parentId) continue
|
||||
if (centerInside(n, zb)) out.push(n.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function buildChildrenByParent(nodes: Node<NodeData>[]): Map<string, string[]> {
|
||||
const m = new Map<string, string[]>()
|
||||
for (const n of nodes) {
|
||||
if (!n.parentId) continue
|
||||
const arr = m.get(n.parentId)
|
||||
if (arr) arr.push(n.id)
|
||||
else m.set(n.parentId, [n.id])
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export interface CollapseInfo {
|
||||
/** Ids the canvas should render. */
|
||||
visibleIds: Set<string>
|
||||
/** For each hidden id, the id of the collapsed ancestor that hid it. */
|
||||
hiddenBy: Map<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for visibility under collapse. O(n) over nodes
|
||||
* (the spatial pass is O(z·n) where z is the number of collapsed zones).
|
||||
*/
|
||||
export function computeCollapseInfo(nodes: Node<NodeData>[]): CollapseInfo {
|
||||
const childrenByParent = buildChildrenByParent(nodes)
|
||||
const hidden = new Set<string>()
|
||||
const hiddenBy = new Map<string, string>()
|
||||
|
||||
const hideSubtree = (rootId: string, hider: string) => {
|
||||
const queue = [...(childrenByParent.get(rootId) ?? [])]
|
||||
while (queue.length > 0) {
|
||||
const id = queue.shift()!
|
||||
if (hidden.has(id)) continue
|
||||
hidden.add(id)
|
||||
if (!hiddenBy.has(id)) hiddenBy.set(id, hider)
|
||||
const sub = childrenByParent.get(id)
|
||||
if (sub) queue.push(...sub)
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 1 — parentId-based collapse (real containers).
|
||||
for (const n of nodes) {
|
||||
if (n.data.collapsed) hideSubtree(n.id, n.id)
|
||||
}
|
||||
|
||||
// Pass 2 — spatial collapse (groupRect zones).
|
||||
for (const n of nodes) {
|
||||
if (n.data.type !== 'groupRect') continue
|
||||
if (!n.data.collapsed) continue
|
||||
const contained = getZoneSpatialChildren(n, nodes)
|
||||
for (const id of contained) {
|
||||
if (!hidden.has(id)) {
|
||||
hidden.add(id)
|
||||
if (!hiddenBy.has(id)) hiddenBy.set(id, n.id)
|
||||
}
|
||||
hideSubtree(id, n.id)
|
||||
}
|
||||
}
|
||||
|
||||
const visibleIds = new Set<string>()
|
||||
for (const n of nodes) {
|
||||
if (!hidden.has(n.id)) visibleIds.add(n.id)
|
||||
}
|
||||
return { visibleIds, hiddenBy }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper kept for call sites that only need the visible set.
|
||||
*/
|
||||
export function getVisibleNodeIds(nodes: Node<NodeData>[]): Set<string> {
|
||||
return computeCollapseInfo(nodes).visibleIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewire edges so that any endpoint inside a collapsed subtree (parentId or
|
||||
* spatial) is replaced with the nearest visible ancestor. See module
|
||||
* docstring for the full rationale.
|
||||
*
|
||||
* - Both endpoints visible → edge kept as-is.
|
||||
* - One endpoint hidden → endpoint replaced by its nearest
|
||||
* visible ancestor; edge surfaces
|
||||
* as a stub on the collapsed zone.
|
||||
* - Both endpoints hidden under the
|
||||
* same visible ancestor → dropped (would be a self-loop).
|
||||
* - Parallel rewires to the same pair → de-duplicated; one stub kept.
|
||||
* (Prevents a 20-device mesh from rendering 20 stacked stubs.)
|
||||
* - Endpoint with no visible ancestor → dropped.
|
||||
*/
|
||||
export function rewireEdgesForCollapse(
|
||||
edges: Edge<EdgeData>[],
|
||||
nodes: Node<NodeData>[],
|
||||
visibleIds: Set<string>,
|
||||
hiddenBy?: Map<string, string>,
|
||||
): Edge<EdgeData>[] {
|
||||
// If the caller already computed hiddenBy (CanvasContainer path), reuse
|
||||
// it. Otherwise recompute — keeps the helper callable from tests without
|
||||
// forcing them to thread the second map through.
|
||||
const hb = hiddenBy ?? computeCollapseInfo(nodes).hiddenBy
|
||||
|
||||
const nearestVisible = (id: string): string | null => {
|
||||
let cur: string | undefined = id
|
||||
const guard = new Set<string>()
|
||||
while (cur !== undefined) {
|
||||
if (visibleIds.has(cur)) return cur
|
||||
if (guard.has(cur)) return null
|
||||
guard.add(cur)
|
||||
cur = hb.get(cur)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const out: Edge<EdgeData>[] = []
|
||||
for (const e of edges) {
|
||||
const src = nearestVisible(e.source)
|
||||
const tgt = nearestVisible(e.target)
|
||||
if (src === null || tgt === null) continue
|
||||
if (src === tgt) continue
|
||||
const key = `${src}->${tgt}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
if (src === e.source && tgt === e.target) {
|
||||
out.push(e)
|
||||
} else {
|
||||
out.push({ ...e, source: src, target: tgt, sourceHandle: null, targetHandle: null })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -7,4 +7,6 @@ export const EDGE_DEFAULT_COLORS: Record<EdgeType, string> = {
|
||||
vlan: '#00d4ff',
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Bottom handle configuration for multi-handle nodes.
|
||||
*
|
||||
* Handle IDs: index 0 = 'bottom' (always the default, backward-compatible)
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 47 = 'bottom-48')
|
||||
* index N≥1 = 'bottom-${N+1}' (so idx 1 = 'bottom-2', idx 63 = 'bottom-64')
|
||||
*
|
||||
* Invisible target handles follow the same pattern with a '-t' suffix:
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-48-t'
|
||||
* 'bottom-t', 'bottom-2-t', ..., 'bottom-64-t'
|
||||
*/
|
||||
|
||||
export const MIN_BOTTOM_HANDLES = 1
|
||||
export const MAX_BOTTOM_HANDLES = 48
|
||||
export const MAX_BOTTOM_HANDLES = 64
|
||||
|
||||
/** Returns the source handle ID at a given slot index. */
|
||||
export function bottomHandleId(idx: number): string {
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NodeProperty } from '@/types'
|
||||
|
||||
/** Build the MAC address property row shown in the right panel.
|
||||
* Hidden by default — the user opts in to showing it on the canvas card.
|
||||
* Matches backend `build_mac_property`. Returns an empty array when no MAC. */
|
||||
export function buildMacProperty(mac?: string | null): NodeProperty[] {
|
||||
if (!mac) return []
|
||||
return [{ key: 'MAC', value: mac, icon: null, visible: false }]
|
||||
}
|
||||
@@ -25,16 +25,17 @@ function maskSingle(ip: string): string {
|
||||
*/
|
||||
export function maskIp(ip: string): string {
|
||||
if (!ip) return ip
|
||||
return ip.split(',').map(maskSingle).join(', ')
|
||||
return splitIps(ip).map(maskSingle).join(', ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a comma-separated IP string into an array of trimmed values.
|
||||
* Empty string returns [].
|
||||
* Split an IP string into trimmed values. Accepts comma, semicolon, or newline
|
||||
* delimiters (or any combination). Duplicates removed, empty entries dropped.
|
||||
*/
|
||||
export function splitIps(ip: string): string[] {
|
||||
if (!ip?.trim()) return []
|
||||
return ip.split(',').map((s) => s.trim()).filter(Boolean)
|
||||
const parts = ip.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||
return parts.filter((v, i) => parts.indexOf(v) === i)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,7 +24,9 @@ import {
|
||||
// Communications
|
||||
Mail, MessageSquare, Phone,
|
||||
// Misc devices
|
||||
Printer, Smartphone, Search, Filter, BookOpen, PlugZap, Type,
|
||||
Printer, Smartphone, Laptop, Search, Filter, BookOpen, PlugZap, Type,
|
||||
// Electrical
|
||||
ToggleLeft,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
@@ -50,6 +52,7 @@ export const ICON_REGISTRY: IconEntry[] = [
|
||||
{ key: 'wifi', label: 'Access Point', category: 'Infrastructure', icon: Wifi },
|
||||
{ key: 'circle', label: 'Generic', category: 'Infrastructure', icon: Circle },
|
||||
{ key: 'monitor', label: 'Workstation', category: 'Infrastructure', icon: Monitor },
|
||||
{ key: 'laptop', label: 'Laptop', category: 'Infrastructure', icon: Laptop },
|
||||
{ key: 'smartphone', label: 'Phone / Mobile', category: 'Infrastructure', icon: Smartphone },
|
||||
{ key: 'printer', label: 'Printer', category: 'Infrastructure', icon: Printer },
|
||||
{ key: 'plugzap', label: 'CPL / Powerline', category: 'Infrastructure', icon: PlugZap },
|
||||
@@ -153,30 +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,
|
||||
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.
|
||||
|
||||
@@ -56,6 +56,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#8b949e', icon: '#8b949e' },
|
||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||
laptop: { border: '#a855f7', icon: '#a855f7' },
|
||||
mobile: { border: '#ec4899', icon: '#ec4899' },
|
||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||
@@ -66,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',
|
||||
@@ -84,6 +100,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#00d4ff',
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical:'#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
@@ -117,6 +135,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
printer: { border: '#94a3b8', icon: '#94a3b8' },
|
||||
computer: { border: '#c084fc', icon: '#c084fc' },
|
||||
laptop: { border: '#c084fc', icon: '#c084fc' },
|
||||
mobile: { border: '#ec4899', icon: '#ec4899' },
|
||||
cpl: { border: '#fbbf24', icon: '#fbbf24' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#38bdf8', icon: '#38bdf8' },
|
||||
@@ -127,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',
|
||||
@@ -145,15 +179,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#22d3ee',
|
||||
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',
|
||||
},
|
||||
},
|
||||
@@ -178,6 +214,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#6b7280', icon: '#6b7280' },
|
||||
printer: { border: '#6b7280', icon: '#6b7280' },
|
||||
computer: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
laptop: { border: '#7c3aed', icon: '#7c3aed' },
|
||||
mobile: { border: '#db2777', icon: '#db2777' },
|
||||
cpl: { border: '#b45309', icon: '#b45309' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#0369a1', icon: '#0369a1' },
|
||||
@@ -188,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',
|
||||
@@ -206,15 +258,17 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#0284c7',
|
||||
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',
|
||||
},
|
||||
},
|
||||
@@ -239,6 +293,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#8888ff', icon: '#8888ff' },
|
||||
printer: { border: '#8888ff', icon: '#8888ff' },
|
||||
computer: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
laptop: { border: '#ff00ff', icon: '#ff00ff' },
|
||||
mobile: { border: '#ff3399', icon: '#ff3399' },
|
||||
cpl: { border: '#ffff00', icon: '#ffff00' },
|
||||
docker_host: { border: '#00aaff', icon: '#00aaff' },
|
||||
docker_container: { border: '#00ddff', icon: '#00ddff' },
|
||||
@@ -249,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',
|
||||
@@ -267,6 +337,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#00ffff',
|
||||
virtual: '#8888cc',
|
||||
cluster: '#ff8800',
|
||||
fibre: '#00e5ff',
|
||||
electrical:'#ffff00',
|
||||
},
|
||||
edgeSelectedColor: '#00ffff',
|
||||
edgeLabelBackground:'#0a0a1a',
|
||||
@@ -300,6 +372,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#005500', icon: '#005500' },
|
||||
printer: { border: '#005500', icon: '#005500' },
|
||||
computer: { border: '#008822', icon: '#008822' },
|
||||
laptop: { border: '#008822', icon: '#008822' },
|
||||
mobile: { border: '#00cc66', icon: '#00cc66' },
|
||||
cpl: { border: '#66ff33', icon: '#66ff33' },
|
||||
docker_host: { border: '#00cc88', icon: '#00cc88' },
|
||||
docker_container: { border: '#00aacc', icon: '#00aacc' },
|
||||
@@ -310,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',
|
||||
@@ -328,6 +416,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#00cc33',
|
||||
virtual: '#004400',
|
||||
cluster: '#33ff66',
|
||||
fibre: '#00ffcc',
|
||||
electrical:'#66ff33',
|
||||
},
|
||||
edgeSelectedColor: '#00ff41',
|
||||
edgeLabelBackground:'#001100',
|
||||
@@ -361,6 +451,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
camera: { border: '#8b949e', icon: '#8b949e' },
|
||||
printer: { border: '#8b949e', icon: '#8b949e' },
|
||||
computer: { border: '#a855f7', icon: '#a855f7' },
|
||||
laptop: { border: '#a855f7', icon: '#a855f7' },
|
||||
mobile: { border: '#ec4899', icon: '#ec4899' },
|
||||
cpl: { border: '#e3b341', icon: '#e3b341' },
|
||||
docker_host: { border: '#2496ED', icon: '#2496ED' },
|
||||
docker_container: { border: '#0ea5e9', icon: '#0ea5e9' },
|
||||
@@ -371,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',
|
||||
@@ -389,6 +495,8 @@ export const THEMES: Record<ThemeId, ThemePreset> = {
|
||||
vlan: '#00d4ff',
|
||||
virtual: '#8b949e',
|
||||
cluster: '#ff6e00',
|
||||
fibre: '#22d3ee',
|
||||
electrical: '#e3b341',
|
||||
},
|
||||
edgeSelectedColor: '#00d4ff',
|
||||
edgeLabelBackground:'#161b22',
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { NodeData } from '@/types'
|
||||
|
||||
export type NodeType = NodeData['type']
|
||||
|
||||
const CONTAINER_MODE_TYPES = new Set<NodeType>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
||||
const DOCKER_CONTAINER_PARENT_TYPES = new Set<NodeType>(['docker_host', 'lxc', 'vm', 'proxmox'])
|
||||
|
||||
export interface VirtualEdgeEndpoint {
|
||||
id: string
|
||||
type: NodeType
|
||||
}
|
||||
|
||||
export interface ParentAssignment {
|
||||
childId: string
|
||||
parentId: string
|
||||
}
|
||||
|
||||
export function getValidParentTypes(childType: NodeType): NodeType[] {
|
||||
if (childType === 'lxc' || childType === 'vm') {
|
||||
return ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||
}
|
||||
if (childType === 'docker_container') {
|
||||
return ['docker_host', 'lxc', 'vm', 'proxmox']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function resolveVirtualEdgeParent(
|
||||
source: VirtualEdgeEndpoint,
|
||||
target: VirtualEdgeEndpoint,
|
||||
): ParentAssignment | null {
|
||||
const { type: srcType, id: srcId } = source
|
||||
const { type: tgtType, id: tgtId } = target
|
||||
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
||||
return { childId: srcId, parentId: tgtId }
|
||||
}
|
||||
if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||
return { childId: tgtId, parentId: srcId }
|
||||
}
|
||||
if (srcType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(tgtType)) {
|
||||
return { childId: srcId, parentId: tgtId }
|
||||
}
|
||||
if (tgtType === 'docker_container' && DOCKER_CONTAINER_PARENT_TYPES.has(srcType)) {
|
||||
return { childId: tgtId, parentId: srcId }
|
||||
}
|
||||
return null
|
||||
}
|
||||
+128
-78
@@ -4,87 +4,133 @@ from mcp.types import Tool, TextContent
|
||||
from .backend_client import backend
|
||||
|
||||
|
||||
NODE_TYPES = ["isp", "router", "switch", "server", "proxmox", "vm", "lxc", "nas", "iot", "ap", "generic"]
|
||||
|
||||
# Shared field schemas mirroring backend NodeBase / NodeUpdate (backend/app/schemas/nodes.py).
|
||||
# create_node and update_node both expose these so the MCP is symmetric with what the
|
||||
# backend already validates and stores. _dispatch forwards args verbatim, so any field
|
||||
# advertised here is accepted by the backend.
|
||||
_NODE_FIELDS = {
|
||||
"label": {"type": "string"},
|
||||
"ip": {"type": "string"},
|
||||
"hostname": {"type": "string"},
|
||||
"mac": {"type": "string", "description": "MAC address."},
|
||||
"os": {"type": "string", "description": "Operating system / distribution."},
|
||||
"status": {"type": "string", "enum": ["online", "offline", "unknown", "pending"]},
|
||||
"check_method": {"type": "string", "description": "Status check method (ping, http, https, ssh, prometheus, tcp)."},
|
||||
"check_target": {"type": "string", "description": "Target host/URL used by the status check."},
|
||||
"services": {"type": "array", "items": {"type": "object"}, "description": "Running services detected or documented on the node."},
|
||||
"notes": {"type": "string", "description": "Free-text notes / documentation for the node."},
|
||||
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
|
||||
"container_mode": {"type": "boolean", "description": "Render this node as a container/group that can hold children."},
|
||||
"custom_icon": {"type": "string", "description": "Override icon name for the node."},
|
||||
"cpu_count": {"type": "integer", "description": "Number of CPU cores/threads."},
|
||||
"cpu_model": {"type": "string", "description": "CPU model name."},
|
||||
"ram_gb": {"type": "number", "description": "RAM in gigabytes."},
|
||||
"disk_gb": {"type": "number", "description": "Disk capacity in gigabytes."},
|
||||
"show_hardware": {"type": "boolean", "description": "Display hardware specs on the node card."},
|
||||
"properties": {
|
||||
"type": "array",
|
||||
"description": "Arbitrary key/value metadata shown on the node.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "value"],
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"value": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_tools() -> list[Tool]:
|
||||
create_node_props = {
|
||||
"type": {"type": "string", "enum": NODE_TYPES},
|
||||
**_NODE_FIELDS,
|
||||
}
|
||||
create_node_props["status"] = {**_NODE_FIELDS["status"], "default": "unknown"}
|
||||
|
||||
update_node_props = {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": NODE_TYPES},
|
||||
**_NODE_FIELDS,
|
||||
}
|
||||
|
||||
return [
|
||||
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["type", "label"],
|
||||
"properties": create_node_props,
|
||||
}),
|
||||
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": update_node_props,
|
||||
}),
|
||||
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["source", "target"],
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["ethernet", "wifi", "iot", "vlan", "virtual"], "default": "ethernet"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
||||
},
|
||||
}),
|
||||
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": NODE_TYPES, "default": "generic"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
]
|
||||
|
||||
|
||||
TOOLS = _build_tools()
|
||||
|
||||
|
||||
def register_tools(server: Server):
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(name="create_node", description="Add a new node to the homelab canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["type", "label"],
|
||||
"properties": {
|
||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"]},
|
||||
"label": {"type": "string"},
|
||||
"ip": {"type": "string"},
|
||||
"hostname": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["online","offline","unknown","pending"], "default": "unknown"},
|
||||
},
|
||||
}),
|
||||
Tool(name="update_node", description="Update an existing node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"ip": {"type": "string"},
|
||||
"hostname": {"type": "string"},
|
||||
"status": {"type": "string"},
|
||||
"parent_id": {"type": "string", "description": "ID of the parent node (e.g. Proxmox host for a VM/LXC). Pass null to detach."},
|
||||
},
|
||||
}),
|
||||
Tool(name="delete_node", description="Delete a node from the canvas", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="create_edge", description="Create a network link between two nodes", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["source", "target"],
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"target": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["ethernet","wifi","iot","vlan","virtual"], "default": "ethernet"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="delete_edge", description="Delete a network link", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="trigger_scan", description="Trigger a network discovery scan", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ranges": {"type": "array", "items": {"type": "string"}, "description": "CIDR ranges to scan (uses configured defaults if omitted)"},
|
||||
},
|
||||
}),
|
||||
Tool(name="approve_device", description="Approve a pending discovered device and create a node", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["isp","router","switch","server","proxmox","vm","lxc","nas","iot","ap","generic"], "default": "generic"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
}),
|
||||
Tool(name="hide_device", description="Hide a pending discovered device", inputSchema={
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {"id": {"type": "string"}},
|
||||
}),
|
||||
Tool(name="get_canvas", description="Get the full canvas: all nodes and edges in the homelab topology", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_nodes", description="List all nodes (devices) in the homelab", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
Tool(name="list_pending_devices", description="List devices discovered by scan but not yet approved or hidden", inputSchema={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}),
|
||||
]
|
||||
return TOOLS
|
||||
|
||||
@server.call_tool()
|
||||
async def call_tool(name: str, arguments: dict):
|
||||
@@ -94,7 +140,11 @@ def register_tools(server: Server):
|
||||
|
||||
def _slim_canvas(raw: dict) -> dict:
|
||||
"""Strip React Flow layout/style fields — keep only semantic data for AI use."""
|
||||
NODE_KEEP = {"id", "type", "label", "ip", "hostname", "status", "services", "description", "parentId"}
|
||||
NODE_KEEP = {
|
||||
"id", "type", "label", "ip", "hostname", "mac", "os", "status", "services",
|
||||
"notes", "description", "properties", "cpu_count", "cpu_model", "ram_gb",
|
||||
"disk_gb", "parentId",
|
||||
}
|
||||
EDGE_KEEP = {"id", "source", "target", "type", "label"}
|
||||
|
||||
def slim_node(n: dict) -> dict:
|
||||
|
||||
+83
-1
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from app.tools import _dispatch
|
||||
from app.tools import TOOLS, _dispatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -32,6 +32,39 @@ async def test_update_node_parent_id(mock_backend):
|
||||
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {"parent_id": "proxmox-1"})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_node_full_properties(mock_backend):
|
||||
args = {
|
||||
"type": "proxmox",
|
||||
"label": "pve1",
|
||||
"os": "Proxmox VE 8",
|
||||
"notes": "Main hypervisor",
|
||||
"services": [{"name": "ssh", "port": 22}],
|
||||
"cpu_count": 16,
|
||||
"cpu_model": "Ryzen 9 5950X",
|
||||
"ram_gb": 64,
|
||||
"disk_gb": 2000,
|
||||
"show_hardware": True,
|
||||
"properties": [{"name": "rack", "value": "A1"}],
|
||||
}
|
||||
await _dispatch("create_node", dict(args))
|
||||
# All extra fields forwarded to the backend unchanged.
|
||||
mock_backend.post.assert_called_once_with("/api/v1/nodes", args)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_node_properties(mock_backend):
|
||||
await _dispatch("update_node", {
|
||||
"id": "42",
|
||||
"os": "Debian 12",
|
||||
"properties": [{"name": "role", "value": "db"}],
|
||||
})
|
||||
mock_backend.patch.assert_called_once_with("/api/v1/nodes/42", {
|
||||
"os": "Debian 12",
|
||||
"properties": [{"name": "role", "value": "db"}],
|
||||
})
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_node(mock_backend):
|
||||
await _dispatch("delete_node", {"id": "42"})
|
||||
@@ -100,6 +133,55 @@ async def test_get_canvas(mock_backend):
|
||||
assert "viewport" not in result
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_canvas_keeps_documentation_fields(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value={
|
||||
"nodes": [
|
||||
{
|
||||
"id": "n1",
|
||||
"type": "proxmox",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {
|
||||
"label": "pve1",
|
||||
"os": "Proxmox VE 8",
|
||||
"notes": "Main hypervisor",
|
||||
"cpu_count": 16,
|
||||
"ram_gb": 64,
|
||||
"properties": [{"name": "rack", "value": "A1"}],
|
||||
},
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
})
|
||||
result = await _dispatch("get_canvas", {})
|
||||
node = result["nodes"][0]
|
||||
assert node["os"] == "Proxmox VE 8"
|
||||
assert node["notes"] == "Main hypervisor"
|
||||
assert node["cpu_count"] == 16
|
||||
assert node["ram_gb"] == 64
|
||||
assert node["properties"] == [{"name": "rack", "value": "A1"}]
|
||||
|
||||
|
||||
def _tool_schema(name: str) -> dict:
|
||||
tool = next(t for t in TOOLS if t.name == name)
|
||||
return tool.inputSchema["properties"]
|
||||
|
||||
|
||||
def test_create_node_schema_exposes_full_node_fields():
|
||||
props = _tool_schema("create_node")
|
||||
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||
assert field in props, f"create_node schema missing {field}"
|
||||
# type stays an enum of the canonical node types
|
||||
assert "enum" in props["type"]
|
||||
|
||||
|
||||
def test_update_node_schema_exposes_full_node_fields():
|
||||
props = _tool_schema("update_node")
|
||||
for field in ("os", "notes", "services", "cpu_count", "ram_gb", "disk_gb", "properties", "mac"):
|
||||
assert field in props, f"update_node schema missing {field}"
|
||||
assert "id" in props
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_list_nodes(mock_backend):
|
||||
mock_backend.get = AsyncMock(return_value=[{"id": "1", "label": "Freebox"}])
|
||||
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/bin/bash
|
||||
# Install / enable the Homelable MCP server as a systemd service.
|
||||
#
|
||||
# Run interactively as root, inside an LXC or any Debian/Ubuntu host.
|
||||
# Typical Proxmox VE flow: create the LXC via the community-scripts/ProxmoxVE
|
||||
# helper, then run this script inside that LXC.
|
||||
#
|
||||
# Idempotent: re-running is safe. If mcp/.env already exists, the script
|
||||
# keeps it untouched and only refreshes the venv + systemd unit.
|
||||
#
|
||||
# Optional env vars (override defaults / skip the matching prompt):
|
||||
# INSTALL_DIR repo root (default: /opt/homelable)
|
||||
# REPO_URL clone URL if $INSTALL_DIR is empty (default: https://github.com/Pouzor/homelable.git)
|
||||
# REPO_REF branch/tag/commit when cloning (default: main)
|
||||
# SERVICE_USER systemd User= (default: homelable)
|
||||
# MCP_PORT listen port (default: 8001)
|
||||
# MCP_API_KEY client → MCP key (default: prompt, auto-gen on empty)
|
||||
# MCP_SERVICE_KEY MCP → backend key (default: prompt, auto-gen on empty; must match backend .env)
|
||||
# BACKEND_URL backend base URL (default: http://127.0.0.1:8000)
|
||||
set -euo pipefail
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-/opt/homelable}"
|
||||
REPO_URL="${REPO_URL:-https://github.com/Pouzor/homelable.git}"
|
||||
REPO_REF="${REPO_REF:-main}"
|
||||
SERVICE_USER="${SERVICE_USER:-homelable}"
|
||||
SERVICE_NAME="homelable-mcp"
|
||||
MCP_PORT="${MCP_PORT:-8001}"
|
||||
DEFAULT_BACKEND_URL="http://127.0.0.1:8000"
|
||||
|
||||
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m!!\033[0m %s\n' "$*" >&2; }
|
||||
fail() { printf '\033[1;31mxx\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[[ $EUID -eq 0 ]] || fail "Run as root (sudo bash $0)."
|
||||
|
||||
log "Installing OS dependencies (git, python3-venv, curl)"
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
|
||||
git python3 python3-venv python3-pip curl iproute2 >/dev/null
|
||||
|
||||
MCP_DIR="$INSTALL_DIR/mcp"
|
||||
if [[ ! -d "$MCP_DIR" ]]; then
|
||||
log "Cloning $REPO_URL ($REPO_REF) → $INSTALL_DIR"
|
||||
mkdir -p "$(dirname "$INSTALL_DIR")"
|
||||
git clone --depth 1 --branch "$REPO_REF" "$REPO_URL" "$INSTALL_DIR"
|
||||
fi
|
||||
[[ -f "$MCP_DIR/requirements.txt" ]] || fail "Missing $MCP_DIR/requirements.txt — repo layout unexpected."
|
||||
|
||||
if ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${MCP_PORT}$"; then
|
||||
warn "Port $MCP_PORT already in use. If it's a previous $SERVICE_NAME instance this is fine; otherwise abort and free the port."
|
||||
fi
|
||||
|
||||
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
log "Creating service user '$SERVICE_USER'"
|
||||
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
ENV_FILE="$MCP_DIR/.env"
|
||||
gen_key() { python3 -c "import secrets;print('$1' + secrets.token_hex(24))"; }
|
||||
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
log ".env already present at $ENV_FILE — keeping existing values"
|
||||
else
|
||||
[[ -f "$MCP_DIR/.env.example" ]] || fail "Missing $MCP_DIR/.env.example"
|
||||
log "No .env found — generating one (press Enter to accept defaults)"
|
||||
|
||||
api_key="${MCP_API_KEY:-}"
|
||||
svc_key="${MCP_SERVICE_KEY:-}"
|
||||
backend_url="${BACKEND_URL:-}"
|
||||
|
||||
if [[ -z "$api_key" ]]; then
|
||||
default_api_key="$(gen_key mcp_sk_)"
|
||||
read -rp "MCP_API_KEY (client → MCP) [default: auto-generate]: " api_key
|
||||
api_key="${api_key:-$default_api_key}"
|
||||
fi
|
||||
if [[ -z "$svc_key" ]]; then
|
||||
default_svc_key="$(gen_key svc_)"
|
||||
read -rp "MCP_SERVICE_KEY (MCP → backend, must match backend .env) [default: auto-generate]: " svc_key
|
||||
svc_key="${svc_key:-$default_svc_key}"
|
||||
fi
|
||||
if [[ -z "$backend_url" ]]; then
|
||||
read -rp "BACKEND_URL [$DEFAULT_BACKEND_URL]: " backend_url
|
||||
backend_url="${backend_url:-$DEFAULT_BACKEND_URL}"
|
||||
fi
|
||||
|
||||
umask 077
|
||||
cat >"$ENV_FILE" <<EOF
|
||||
MCP_API_KEY=$api_key
|
||||
MCP_SERVICE_KEY=$svc_key
|
||||
BACKEND_URL=$backend_url
|
||||
EOF
|
||||
log "Wrote $ENV_FILE (mode 600)"
|
||||
warn "If the backend runs elsewhere, set the SAME MCP_SERVICE_KEY in its .env."
|
||||
fi
|
||||
|
||||
VENV="$MCP_DIR/.venv"
|
||||
if [[ ! -d "$VENV" ]]; then
|
||||
log "Creating venv at $VENV"
|
||||
python3 -m venv "$VENV"
|
||||
fi
|
||||
log "Installing Python deps"
|
||||
"$VENV/bin/pip" install --quiet --upgrade pip
|
||||
"$VENV/bin/pip" install --quiet -r "$MCP_DIR/requirements.txt"
|
||||
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$MCP_DIR"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
UNIT="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
log "Writing $UNIT"
|
||||
cat >"$UNIT" <<EOF
|
||||
[Unit]
|
||||
Description=Homelable MCP server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$SERVICE_USER
|
||||
WorkingDirectory=$MCP_DIR
|
||||
EnvironmentFile=$ENV_FILE
|
||||
ExecStart=$VENV/bin/uvicorn app.main:app --host 0.0.0.0 --port $MCP_PORT
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
log "Waiting for MCP to come up on :$MCP_PORT"
|
||||
ok=0
|
||||
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
||||
if curl -fsS "http://127.0.0.1:${MCP_PORT}/health" >/dev/null 2>&1; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [[ "$ok" -ne 1 ]]; then
|
||||
warn "MCP did not respond on /health within 10s. Check: journalctl -u $SERVICE_NAME -n 50"
|
||||
else
|
||||
log "MCP server is up."
|
||||
fi
|
||||
|
||||
LXC_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
|
||||
API_KEY_VALUE="$(grep -E '^MCP_API_KEY=' "$ENV_FILE" | cut -d= -f2-)"
|
||||
|
||||
cat <<EOF
|
||||
|
||||
----------------------------------------------------------------
|
||||
MCP server installed.
|
||||
|
||||
Service: $SERVICE_NAME (systemctl status $SERVICE_NAME)
|
||||
Listen: http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp
|
||||
Env file: $ENV_FILE
|
||||
Logs: journalctl -u $SERVICE_NAME -f
|
||||
|
||||
Claude Code client setup:
|
||||
claude mcp add --transport sse homelable http://${LXC_IP:-<lxc-ip>}:${MCP_PORT}/mcp \\
|
||||
--header "X-API-Key: $API_KEY_VALUE"
|
||||
----------------------------------------------------------------
|
||||
EOF
|
||||
Reference in New Issue
Block a user