Compare commits
190 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12d527aad6 | |||
| eb7b0c6d38 | |||
| be320c2ac4 | |||
| 60ce648a96 | |||
| d5e35a885d | |||
| 3a57d809a4 | |||
| 46435605eb | |||
| 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 | |||
| 994ed9d77a | |||
| 4d4befa916 | |||
| 8f741691d0 | |||
| c21fbac599 | |||
| e9c66fceda | |||
| 373960f6ea | |||
| aa17edf1d0 | |||
| 5f87c64dcf | |||
| 5822a1483a | |||
| 3a303a1376 | |||
| f5e0e68806 | |||
| 8541922386 | |||
| 3ed9cb0d4f | |||
| fff11a4b6a | |||
| 890463373a | |||
| 0680566081 | |||
| 2f5a90a00e | |||
| 928f63df0f | |||
| e84a4e0eb3 | |||
| 3b0dbd7a8b | |||
| 3a9b3b2650 | |||
| ff02f3b5db | |||
| a66e6aa906 | |||
| 8e59d9a0d1 | |||
| ee4136b506 | |||
| 8e8f01d6ca | |||
| aba4f34168 | |||
| 6524efe126 | |||
| 062a08f116 | |||
| 5e9963db4d | |||
| 82203f67d8 | |||
| 004a8f19c1 | |||
| 5ab0bdeb7f | |||
| 3f9866e8a1 | |||
| ebdf6cb55b | |||
| ec8f1c87f1 | |||
| 2b91f31807 | |||
| c6965b827b | |||
| afdea65d4e | |||
| 32eb9fbe23 | |||
| 27fd78ece6 | |||
| e672cb086a | |||
| 3cddcf5b81 | |||
| 19791abf5b | |||
| d9d4be9a53 | |||
| c165b8b016 | |||
| b16e81cf29 | |||
| 3028e5a3f7 | |||
| 340bd15099 | |||
| d181cef4c1 | |||
| 4a048d0ab5 | |||
| f32c32f6a5 | |||
| d1f170d5db | |||
| 0a680fb9f2 | |||
| 9db41e886a | |||
| 0863c2db94 | |||
| b17299f531 | |||
| 277879488f | |||
| 456f26a85a | |||
| 2a6140a6b1 | |||
| 3ae159d8d6 | |||
| 5e567d4628 | |||
| 05db9a59f5 | |||
| a46e505505 | |||
| 5b5866d8d6 | |||
| 9970780e7a | |||
| 53aeb82af1 | |||
| d49610163f | |||
| 517130a9ef | |||
| c90538b1d0 | |||
| 67b0620be2 | |||
| cc9c010002 | |||
| 103e24e5fa | |||
| 8110ee075d | |||
| 2994f8653a | |||
| 5fb77ab00b | |||
| 56cfbd1e76 | |||
| 43761c60cb | |||
| ad958feabd | |||
| e3876e934c | |||
| dfa4a9c849 | |||
| 785be6a5dd | |||
| d5f4a9f729 | |||
| 8859893e42 | |||
| 93b98f760c | |||
| 30ed78c240 | |||
| 39f8d16ef1 | |||
| 0095bf8425 | |||
| 69436c438c | |||
| 2cc97a6de9 | |||
| 29a2ef1b20 | |||
| 0c836e0575 | |||
| 6518eb313b | |||
| 09a591f5f4 | |||
| 988b804b90 | |||
| 6fa0ada325 | |||
| 17613f42d1 | |||
| 0a44b69c4e | |||
| 84235d81bf | |||
| 31b61904ac | |||
| babbcb1dc5 | |||
| 12fde681ba | |||
| 63922f0841 | |||
| 896cd4fa21 | |||
| 8c9f1a23e0 | |||
| 18f9bb7bdf | |||
| da287d459c | |||
| adb2088752 | |||
| cd0e08fb91 | |||
| 3ccdde0bea | |||
| d0a49d0a0d | |||
| 31b5bc4515 | |||
| b05d70663c | |||
| 4e68af7cac | |||
| 528c362633 | |||
| bfe520cd49 | |||
| 29c97ae501 | |||
| 4ecd241bf4 | |||
| 96786f155b | |||
| 1d6127fed3 | |||
| 26633f760d | |||
| 745002593f | |||
| 1a978c5e51 | |||
| 16adff5cff | |||
| 45b0965fb7 | |||
| f1bcd6ef78 | |||
| 26be37f731 | |||
| bfd7ccd36c | |||
| ada30311ed | |||
| 6ead20125d | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 892710faac | |||
| 431fb47498 |
@@ -28,3 +28,9 @@ MCP_SERVICE_KEY=svc_changeme
|
|||||||
# Off by default. Set to a random secret to enable.
|
# Off by default. Set to a random secret to enable.
|
||||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# LIVEVIEW_KEY=
|
# LIVEVIEW_KEY=
|
||||||
|
|
||||||
|
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
||||||
|
# Off by default. Set to a random secret to enable; clients must send
|
||||||
|
# the same value in the `X-API-Key` header.
|
||||||
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
# HOMEPAGE_API_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
|
patreon: # Replace with a single Patreon username
|
||||||
|
open_collective: # Replace with a single Open Collective username
|
||||||
|
ko_fi: pouzor
|
||||||
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
liberapay: # Replace with a single Liberapay username
|
||||||
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
|
polar: # Replace with a single Polar username
|
||||||
|
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||||
|
thanks_dev: # Replace with a single thanks.dev username
|
||||||
|
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||||
@@ -16,14 +16,21 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: ghcr.io/pouzor/homelable-backend
|
- image: ghcr.io/pouzor/homelable-backend
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.backend
|
dockerfile: Dockerfile.backend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend
|
- image: ghcr.io/pouzor/homelable-frontend
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||||
|
context: .
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: "VITE_STANDALONE=true"
|
build_args: "VITE_STANDALONE=true"
|
||||||
|
- image: ghcr.io/pouzor/homelable-mcp
|
||||||
|
context: ./mcp
|
||||||
|
dockerfile: Dockerfile.mcp
|
||||||
|
build_args: ""
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -55,8 +62,8 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: ${{ matrix.context }}
|
||||||
file: ${{ matrix.dockerfile }}
|
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# Homelable
|
# Homelable
|
||||||
|
|
||||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
||||||
|
|
||||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
|
|
||||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
|
If you are running <img width="35" height="35" align="middle" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,8 +18,9 @@ If you just like the design, you can only run the frontend and export your desig
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
|
||||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||||
|
<img width="48%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/06caab68-6637-4dda-ab16-7e83f63d3972" />
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -74,6 +77,38 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Zigbee2MQTT Import
|
||||||
|
|
||||||
|
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||||
|
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
||||||
|
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
||||||
|
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
||||||
|
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
||||||
|
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
||||||
|
|
||||||
|
### Node Types
|
||||||
|
|
||||||
|
| Type | Z2M Device | Icon |
|
||||||
|
|------|-----------|------|
|
||||||
|
| `zigbee_coordinator` | Coordinator | Network hub |
|
||||||
|
| `zigbee_router` | Router (mains-powered) | Radio |
|
||||||
|
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
||||||
|
|
||||||
|
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
||||||
|
LQI (Link Quality Indicator) is stored as a node property.
|
||||||
|
|
||||||
|
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Live View (read-only public canvas)
|
## Live View (read-only public canvas)
|
||||||
|
|
||||||
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
||||||
@@ -99,7 +134,61 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optionnal)
|
## Gethomepage Widget (read-only stats)
|
||||||
|
|
||||||
|
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Add `HOMEPAGE_API_KEY` to your `.env`:
|
||||||
|
|
||||||
|
`HOMEPAGE_API_KEY=your-secret-key`
|
||||||
|
|
||||||
|
Restart the backend (`docker compose restart backend`).
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nodes": 12,
|
||||||
|
"online": 9,
|
||||||
|
"offline": 2,
|
||||||
|
"unknown": 1,
|
||||||
|
"pending_devices": 3,
|
||||||
|
"zigbee_devices": 5,
|
||||||
|
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### gethomepage `services.yaml` snippet
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- Homelab:
|
||||||
|
- Homelable:
|
||||||
|
icon: mdi-lan
|
||||||
|
href: http://homelable.local:3000
|
||||||
|
widget:
|
||||||
|
type: customapi
|
||||||
|
url: http://homelable.local:8000/api/v1/stats/summary
|
||||||
|
method: GET
|
||||||
|
headers:
|
||||||
|
X-API-Key: your-secret-key
|
||||||
|
mappings:
|
||||||
|
- field: nodes ; label: Nodes
|
||||||
|
- field: online ; label: Online
|
||||||
|
- field: offline ; label: Offline
|
||||||
|
- field: pending_devices ; label: Pending
|
||||||
|
- field: zigbee_devices ; label: Zigbee
|
||||||
|
- field: last_scan_at ; label: Last scan
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend port (`8000`) must be reachable from your gethomepage container.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP Server (AI Integration) (optional)
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
@@ -134,6 +223,12 @@ docker compose up -d mcp
|
|||||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
# 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:**
|
**3. Configure your AI client:**
|
||||||
|
|
||||||
**Claude Code** — run this command in your terminal:
|
**Claude Code** — run this command in your terminal:
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Edge, Node
|
from app.db.models import CanvasState, Design, Edge, Node
|
||||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
@@ -16,33 +17,54 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
async def load_canvas(
|
||||||
nodes = (await db.execute(select(Node))).scalars().all()
|
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
db: AsyncSession = Depends(get_db),
|
||||||
state = await db.get(CanvasState, 1)
|
_: str = Depends(get_current_user),
|
||||||
|
) -> CanvasStateResponse:
|
||||||
|
if design_id is None:
|
||||||
|
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
|
design_id = first.id if first else None
|
||||||
|
if design_id is None:
|
||||||
|
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||||
|
|
||||||
|
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||||
|
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||||
|
state = await db.get(CanvasState, design_id)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=state.custom_style if state else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/save")
|
@router.post("/save")
|
||||||
async def save_canvas(
|
async def save_canvas(
|
||||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool | 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_node_ids = {n.id for n in body.nodes}
|
||||||
incoming_edge_ids = {e.id for e in body.edges}
|
incoming_edge_ids = {e.id for e in body.edges}
|
||||||
|
|
||||||
# Delete nodes removed from canvas
|
# Delete nodes removed from canvas (only within this design)
|
||||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||||
for node in existing_nodes:
|
for node in existing_nodes:
|
||||||
if node.id not in incoming_node_ids:
|
if node.id not in incoming_node_ids:
|
||||||
await db.delete(node)
|
await db.delete(node)
|
||||||
|
|
||||||
# Delete edges removed from canvas
|
# Delete edges removed from canvas (only within this design)
|
||||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||||
for edge in existing_edges:
|
for edge in existing_edges:
|
||||||
if edge.id not in incoming_edge_ids:
|
if edge.id not in incoming_edge_ids:
|
||||||
await db.delete(edge)
|
await db.delete(edge)
|
||||||
@@ -52,28 +74,33 @@ async def save_canvas(
|
|||||||
# Upsert nodes
|
# Upsert nodes
|
||||||
for node_data in body.nodes:
|
for node_data in body.nodes:
|
||||||
db_node = await db.get(Node, node_data.id)
|
db_node = await db.get(Node, node_data.id)
|
||||||
|
payload = node_data.model_dump()
|
||||||
|
payload["design_id"] = design_id
|
||||||
if db_node:
|
if db_node:
|
||||||
for field, value in node_data.model_dump().items():
|
for field, value in payload.items():
|
||||||
setattr(db_node, field, value)
|
setattr(db_node, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Node(**node_data.model_dump()))
|
db.add(Node(**payload))
|
||||||
|
|
||||||
# Upsert edges
|
# Upsert edges
|
||||||
for edge_data in body.edges:
|
for edge_data in body.edges:
|
||||||
db_edge = await db.get(Edge, edge_data.id)
|
db_edge = await db.get(Edge, edge_data.id)
|
||||||
|
payload = edge_data.model_dump()
|
||||||
|
payload["design_id"] = design_id
|
||||||
if db_edge:
|
if db_edge:
|
||||||
for field, value in edge_data.model_dump().items():
|
for field, value in payload.items():
|
||||||
setattr(db_edge, field, value)
|
setattr(db_edge, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Edge(**edge_data.model_dump()))
|
db.add(Edge(**payload))
|
||||||
|
|
||||||
# Upsert viewport
|
# Upsert viewport + custom style
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, design_id)
|
||||||
if state:
|
if state:
|
||||||
state.viewport = body.viewport
|
state.viewport = body.viewport
|
||||||
|
state.custom_style = body.custom_style
|
||||||
state.saved_at = datetime.now(timezone.utc)
|
state.saved_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
|
|||||||
@@ -0,0 +1,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()
|
||||||
@@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Edge, Node
|
from app.db.models import CanvasState, Design, Edge, Node
|
||||||
from app.schemas.canvas import CanvasStateResponse
|
from app.schemas.canvas import CanvasStateResponse
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
@@ -18,6 +18,7 @@ router = APIRouter()
|
|||||||
@router.get("", response_model=CanvasStateResponse)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def liveview_canvas(
|
async def liveview_canvas(
|
||||||
key: str | None = Query(default=None),
|
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),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CanvasStateResponse:
|
) -> CanvasStateResponse:
|
||||||
"""Read-only public canvas endpoint.
|
"""Read-only public canvas endpoint.
|
||||||
@@ -30,12 +31,20 @@ async def liveview_canvas(
|
|||||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||||
|
|
||||||
nodes = (await db.execute(select(Node))).scalars().all()
|
if design_id is None:
|
||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||||
state = await db.get(CanvasState, 1)
|
design_id = first.id if first else None
|
||||||
|
if design_id is None:
|
||||||
|
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||||
|
|
||||||
|
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||||
|
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||||
|
state = await db.get(CanvasState, design_id)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
|
custom_style=custom_style,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,10 +11,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.scanner import request_cancel, run_scan
|
from app.services.scanner import request_cancel, run_scan
|
||||||
|
from app.services.zigbee_service import build_zigbee_properties
|
||||||
|
|
||||||
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
|
|
||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
@@ -115,6 +118,10 @@ async def bulk_approve_devices(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> 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(
|
result = await db.execute(
|
||||||
select(PendingDevice).where(
|
select(PendingDevice).where(
|
||||||
PendingDevice.id.in_(payload.device_ids),
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
@@ -125,24 +132,41 @@ async def bulk_approve_devices(
|
|||||||
created_nodes: list[Node] = []
|
created_nodes: list[Node] = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
|
node_type = device.suggested_type or "generic"
|
||||||
|
is_zigbee = node_type in _ZIGBEE_TYPES
|
||||||
node = Node(
|
node = Node(
|
||||||
label=device.hostname or device.ip,
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
type=device.suggested_type or "generic",
|
type=node_type,
|
||||||
ip=device.ip,
|
ip=device.ip,
|
||||||
hostname=device.hostname,
|
hostname=device.hostname,
|
||||||
status="unknown",
|
status="online" if is_zigbee else "unknown",
|
||||||
services=device.services or [],
|
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 [],
|
||||||
|
# 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)
|
db.add(node)
|
||||||
created_nodes.append(node)
|
created_nodes.append(node)
|
||||||
await db.flush() # populates node.id from Python-side default before reading
|
await db.flush() # populates node.id from Python-side default before reading
|
||||||
node_ids = [n.id for n in created_nodes]
|
node_ids = [n.id for n in created_nodes]
|
||||||
approved_device_ids = [d.id for d in devices]
|
approved_device_ids = [d.id for d in devices]
|
||||||
|
|
||||||
|
all_edges: list[dict[str, str]] = []
|
||||||
|
for device in devices:
|
||||||
|
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
"approved": len(node_ids),
|
"approved": len(node_ids),
|
||||||
"node_ids": node_ids,
|
"node_ids": node_ids,
|
||||||
"device_ids": approved_device_ids,
|
"device_ids": approved_device_ids,
|
||||||
|
"edges_created": len(all_edges),
|
||||||
|
"edges": all_edges,
|
||||||
"skipped": len(payload.device_ids) - len(node_ids),
|
"skipped": len(payload.device_ids) - len(node_ids),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +190,41 @@ async def bulk_hide_devices(
|
|||||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/{device_id}/restore", response_model=dict)
|
||||||
|
async def restore_device(
|
||||||
|
device_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
device = await db.get(PendingDevice, device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
if device.status != "hidden":
|
||||||
|
raise HTTPException(status_code=409, detail="Device is not hidden")
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": True, "device_id": device_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pending/bulk-restore", response_model=dict)
|
||||||
|
async def bulk_restore_devices(
|
||||||
|
payload: BulkActionRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.id.in_(payload.device_ids),
|
||||||
|
PendingDevice.status == "hidden",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
for device in devices:
|
||||||
|
device.status = "pending"
|
||||||
|
await db.commit()
|
||||||
|
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
async def approve_device(
|
async def approve_device(
|
||||||
device_id: str,
|
device_id: str,
|
||||||
@@ -173,25 +232,134 @@ async def approve_device(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
if device.status != "pending":
|
if device.status != "pending":
|
||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
raise HTTPException(status_code=409, detail="Device already processed")
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
|
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
||||||
node = Node(
|
node = Node(
|
||||||
label=node_data.label,
|
label=node_data.label,
|
||||||
type=node_data.type,
|
type=node_data.type,
|
||||||
ip=node_data.ip,
|
ip=node_data.ip,
|
||||||
hostname=node_data.hostname,
|
hostname=node_data.hostname,
|
||||||
status=node_data.status,
|
status="online" if _is_zigbee else node_data.status,
|
||||||
services=node_data.services or [],
|
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 []),
|
||||||
|
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)
|
db.add(node)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
node_id = node.id
|
node_id = node.id
|
||||||
|
|
||||||
|
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"approved": True, "node_id": node_id}
|
return {
|
||||||
|
"approved": True,
|
||||||
|
"node_id": node_id,
|
||||||
|
"edges_created": len(edges),
|
||||||
|
"edges": edges,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_pending_links_for_ieee(
|
||||||
|
db: AsyncSession, ieee: str | None
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Materialize edges for any pending_device_links involving ``ieee``.
|
||||||
|
|
||||||
|
For each link where the other endpoint already exists as a canvas Node
|
||||||
|
(matched by ``Node.ieee_address``), create the Edge and drop the link
|
||||||
|
row. Links where the other endpoint is still pending are kept so they
|
||||||
|
can resolve when that endpoint is approved later.
|
||||||
|
"""
|
||||||
|
if not ieee:
|
||||||
|
return []
|
||||||
|
|
||||||
|
links_q = await db.execute(
|
||||||
|
select(PendingDeviceLink).where(
|
||||||
|
(PendingDeviceLink.source_ieee == ieee)
|
||||||
|
| (PendingDeviceLink.target_ieee == ieee)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
links = list(links_q.scalars().all())
|
||||||
|
if not links:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Map every relevant ieee → Node (single query).
|
||||||
|
other_ieees = {
|
||||||
|
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||||
|
for link in links
|
||||||
|
}
|
||||||
|
other_ieees.add(ieee)
|
||||||
|
nodes_q = await db.execute(
|
||||||
|
select(Node).where(Node.ieee_address.in_(other_ieees))
|
||||||
|
)
|
||||||
|
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||||
|
|
||||||
|
self_node = by_ieee.get(ieee)
|
||||||
|
if self_node is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Pre-fetch existing edges between these node ids so we don't create dups
|
||||||
|
# if the user re-approves a device or had drawn the link manually.
|
||||||
|
candidate_node_ids = [n.id for n in by_ieee.values()]
|
||||||
|
existing_q = await db.execute(
|
||||||
|
select(Edge).where(
|
||||||
|
Edge.source.in_(candidate_node_ids),
|
||||||
|
Edge.target.in_(candidate_node_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
|
||||||
|
|
||||||
|
created: list[dict[str, str]] = []
|
||||||
|
for link in links:
|
||||||
|
other_ieee = (
|
||||||
|
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||||
|
)
|
||||||
|
other_node = by_ieee.get(other_ieee)
|
||||||
|
if other_node is None:
|
||||||
|
continue
|
||||||
|
if link.source_ieee == ieee:
|
||||||
|
src_id, tgt_id = self_node.id, other_node.id
|
||||||
|
else:
|
||||||
|
src_id, tgt_id = other_node.id, self_node.id
|
||||||
|
# Skip if either direction already exists.
|
||||||
|
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()
|
||||||
|
existing_pairs.add((src_id, tgt_id))
|
||||||
|
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
|
||||||
|
await db.delete(link)
|
||||||
|
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/hide")
|
@router.post("/pending/{device_id}/hide")
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import hmac
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _check_key(x_api_key: str | None) -> None:
|
||||||
|
if not settings.homepage_api_key:
|
||||||
|
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
||||||
|
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/summary")
|
||||||
|
async def summary(
|
||||||
|
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Read-only stats payload for the gethomepage `customapi` widget.
|
||||||
|
|
||||||
|
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
||||||
|
value in the `X-API-Key` header.
|
||||||
|
"""
|
||||||
|
_check_key(x_api_key)
|
||||||
|
|
||||||
|
status_rows = (
|
||||||
|
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||||
|
).all()
|
||||||
|
counts = {row[0]: row[1] for row in status_rows}
|
||||||
|
|
||||||
|
pending = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(PendingDevice)
|
||||||
|
.where(PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
zigbee = (
|
||||||
|
await db.execute(
|
||||||
|
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
last_scan_at = (
|
||||||
|
await db.execute(select(func.max(ScanRun.finished_at)))
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodes": sum(counts.values()),
|
||||||
|
"online": counts.get("online", 0),
|
||||||
|
"offline": counts.get("offline", 0),
|
||||||
|
"unknown": counts.get("unknown", 0),
|
||||||
|
"pending_devices": pending,
|
||||||
|
"zigbee_devices": zigbee,
|
||||||
|
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
"""FastAPI router for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
|
from sqlalchemy import delete as sa_delete
|
||||||
|
from sqlalchemy import select
|
||||||
|
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 Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
|
from app.schemas.scan import ScanRunResponse
|
||||||
|
from app.schemas.zigbee import (
|
||||||
|
ZigbeeCoordinatorOut,
|
||||||
|
ZigbeeEdgeOut,
|
||||||
|
ZigbeeImportPendingResponse,
|
||||||
|
ZigbeeImportRequest,
|
||||||
|
ZigbeeImportResponse,
|
||||||
|
ZigbeeNodeOut,
|
||||||
|
ZigbeeTestConnectionRequest,
|
||||||
|
ZigbeeTestConnectionResponse,
|
||||||
|
)
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
build_zigbee_properties,
|
||||||
|
fetch_networkmap,
|
||||||
|
merge_zigbee_properties,
|
||||||
|
test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||||
|
async def import_zigbee_network(
|
||||||
|
payload: ZigbeeImportRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeImportResponse:
|
||||||
|
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||||
|
|
||||||
|
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||||
|
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
||||||
|
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
||||||
|
coordinator → router → end-device hierarchy.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
nodes_raw, edges_raw = await fetch_networkmap(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
base_topic=payload.base_topic,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
except ConnectionError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||||
|
except TimeoutError as exc:
|
||||||
|
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Unexpected error during Zigbee import")
|
||||||
|
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||||
|
|
||||||
|
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||||
|
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||||
|
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||||
|
async def import_zigbee_to_pending(
|
||||||
|
payload: ZigbeeImportRequest,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ScanRun:
|
||||||
|
"""Queue a Zigbee2MQTT pending import as a background scan run.
|
||||||
|
|
||||||
|
Returns the ScanRun row immediately so the UI can close the import
|
||||||
|
modal and surface progress under Scan History (kind=zigbee). The
|
||||||
|
actual MQTT fetch + pending upsert happens in the background.
|
||||||
|
"""
|
||||||
|
run = ScanRun(
|
||||||
|
status="running",
|
||||||
|
kind="zigbee",
|
||||||
|
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(run)
|
||||||
|
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
try:
|
||||||
|
nodes_raw, edges_raw = await fetch_networkmap(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
base_topic=payload.base_topic,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "done"
|
||||||
|
run.devices_found = result.device_count
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Zigbee import %s failed", run_id)
|
||||||
|
await db.rollback()
|
||||||
|
run = await db.get(ScanRun, run_id)
|
||||||
|
if run:
|
||||||
|
run.status = "error"
|
||||||
|
run.error = str(exc)[:500]
|
||||||
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_pending_import(
|
||||||
|
db: AsyncSession,
|
||||||
|
nodes_raw: list[dict[str, Any]],
|
||||||
|
edges_raw: list[dict[str, Any]],
|
||||||
|
) -> ZigbeeImportPendingResponse:
|
||||||
|
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||||
|
|
||||||
|
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
|
||||||
|
pending_updated = 0
|
||||||
|
|
||||||
|
for n in nodes_raw:
|
||||||
|
ieee = n.get("ieee_address")
|
||||||
|
if not ieee:
|
||||||
|
continue
|
||||||
|
props = build_zigbee_properties(
|
||||||
|
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||||
|
)
|
||||||
|
|
||||||
|
if n.get("device_type") == "Coordinator":
|
||||||
|
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
||||||
|
existing_node = existing.scalar_one_or_none()
|
||||||
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
|
coordinator_out = ZigbeeCoordinatorOut(
|
||||||
|
id=existing_node.id,
|
||||||
|
label=existing_node.label,
|
||||||
|
ieee_address=ieee,
|
||||||
|
)
|
||||||
|
coordinator_existed = True
|
||||||
|
continue
|
||||||
|
label = n.get("friendly_name") or ieee
|
||||||
|
node = Node(
|
||||||
|
label=label,
|
||||||
|
type=n.get("type") or "zigbee_coordinator",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address=ieee,
|
||||||
|
services=[],
|
||||||
|
properties=props,
|
||||||
|
design_id=default_design_id,
|
||||||
|
)
|
||||||
|
db.add(node)
|
||||||
|
await db.flush()
|
||||||
|
coordinator_out = ZigbeeCoordinatorOut(
|
||||||
|
id=node.id, label=label, ieee_address=ieee
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If the device has already been approved as a canvas Node, refresh
|
||||||
|
# its properties and skip creating a pending row (keeps approved
|
||||||
|
# devices out of pending/hidden modals on re-import).
|
||||||
|
existing_node_q = await db.execute(
|
||||||
|
select(Node).where(Node.ieee_address == ieee)
|
||||||
|
)
|
||||||
|
existing_node = existing_node_q.scalar_one_or_none()
|
||||||
|
if existing_node:
|
||||||
|
existing_node.properties = merge_zigbee_properties(
|
||||||
|
existing_node.properties, props
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
|
)
|
||||||
|
pending = result.scalar_one_or_none()
|
||||||
|
if pending is None:
|
||||||
|
db.add(
|
||||||
|
PendingDevice(
|
||||||
|
ieee_address=ieee,
|
||||||
|
friendly_name=n.get("friendly_name"),
|
||||||
|
hostname=n.get("friendly_name"),
|
||||||
|
suggested_type=n.get("type"),
|
||||||
|
device_subtype=n.get("device_type"),
|
||||||
|
model=n.get("model"),
|
||||||
|
vendor=n.get("vendor"),
|
||||||
|
lqi=n.get("lqi"),
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pending_created += 1
|
||||||
|
else:
|
||||||
|
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
||||||
|
pending.suggested_type = n.get("type") or pending.suggested_type
|
||||||
|
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||||
|
pending.model = n.get("model") or pending.model
|
||||||
|
pending.vendor = n.get("vendor") or pending.vendor
|
||||||
|
if n.get("lqi") is not None:
|
||||||
|
pending.lqi = n.get("lqi")
|
||||||
|
if pending.status == "hidden":
|
||||||
|
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||||
|
pass
|
||||||
|
pending_updated += 1
|
||||||
|
|
||||||
|
# Replace all zigbee-source links with the freshly discovered set.
|
||||||
|
await db.execute(
|
||||||
|
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
|
||||||
|
)
|
||||||
|
|
||||||
|
links_recorded = 0
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for e in edges_raw:
|
||||||
|
src = e.get("source")
|
||||||
|
tgt = e.get("target")
|
||||||
|
if not src or not tgt or (src, tgt) in seen:
|
||||||
|
continue
|
||||||
|
seen.add((src, tgt))
|
||||||
|
db.add(
|
||||||
|
PendingDeviceLink(
|
||||||
|
source_ieee=src,
|
||||||
|
target_ieee=tgt,
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
links_recorded += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return ZigbeeImportPendingResponse(
|
||||||
|
pending_created=pending_created,
|
||||||
|
pending_updated=pending_updated,
|
||||||
|
coordinator=coordinator_out,
|
||||||
|
coordinator_already_existed=coordinator_existed,
|
||||||
|
links_recorded=links_recorded,
|
||||||
|
device_count=len(nodes_raw),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
||||||
|
async def test_zigbee_connection(
|
||||||
|
payload: ZigbeeTestConnectionRequest,
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> ZigbeeTestConnectionResponse:
|
||||||
|
"""Quick MQTT ping to validate broker connection before importing."""
|
||||||
|
try:
|
||||||
|
await test_mqtt_connection(
|
||||||
|
mqtt_host=payload.mqtt_host,
|
||||||
|
mqtt_port=payload.mqtt_port,
|
||||||
|
username=payload.mqtt_username,
|
||||||
|
password=payload.mqtt_password,
|
||||||
|
tls=payload.mqtt_tls,
|
||||||
|
tls_insecure=payload.mqtt_tls_insecure,
|
||||||
|
)
|
||||||
|
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
|
||||||
|
except ImportError as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
except (ConnectionError, TimeoutError) as exc:
|
||||||
|
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected error during connection test")
|
||||||
|
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||||
@@ -61,6 +61,11 @@ class Settings(BaseSettings):
|
|||||||
# Leave unset (or empty) to keep the feature disabled (default).
|
# Leave unset (or empty) to keep the feature disabled (default).
|
||||||
liveview_key: str | None = None
|
liveview_key: str | None = None
|
||||||
|
|
||||||
|
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
||||||
|
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
||||||
|
# Leave empty to keep the feature disabled (default).
|
||||||
|
homepage_api_key: str = ""
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
if not plain or not hashed:
|
||||||
|
return False
|
||||||
try:
|
try:
|
||||||
return bool(pwd_context.verify(plain, hashed))
|
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||||
except ValueError:
|
except (ValueError, TypeError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return str(pwd_context.hash(password))
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str) -> str:
|
def create_access_token(subject: str) -> str:
|
||||||
|
|||||||
+188
-4
@@ -1,17 +1,36 @@
|
|||||||
|
import json as _json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
|
import uuid as _uuid_mod
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.exc import OperationalError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.core.config import APP_VERSION, settings
|
from app.core.config import APP_VERSION, settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
||||||
|
"""Run an idempotent migration statement, logging any error.
|
||||||
|
|
||||||
|
Distinguishes 'already applied' errors (debug) from genuine failures
|
||||||
|
(warning) so silent corruption is avoided. Used for new in-commit
|
||||||
|
migrations; existing legacy ALTERs above remain wrapped in suppress.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await conn.exec_driver_sql(sql)
|
||||||
|
except OperationalError as exc:
|
||||||
|
msg = str(exc).lower()
|
||||||
|
if "duplicate column" in msg or "already exists" in msg:
|
||||||
|
logger.debug("Migration %s skipped (already applied): %s", label, exc)
|
||||||
|
else:
|
||||||
|
logger.warning("Migration %s failed: %s", label, exc)
|
||||||
|
|
||||||
# Ensure the data directory exists before SQLite tries to open the file
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -80,6 +99,172 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
|
||||||
|
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
|
||||||
|
zigbee_migrations: list[tuple[str, str]] = [
|
||||||
|
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
|
||||||
|
(
|
||||||
|
"nodes.ieee_address.index",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
||||||
|
),
|
||||||
|
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
|
||||||
|
(
|
||||||
|
"pending_devices.ieee_address.index",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||||
|
"ON pending_devices(ieee_address)",
|
||||||
|
),
|
||||||
|
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
|
||||||
|
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
|
||||||
|
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
|
||||||
|
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
|
||||||
|
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
|
||||||
|
]
|
||||||
|
for label, sql in zigbee_migrations:
|
||||||
|
await _try_migrate(conn, sql, label=label)
|
||||||
|
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
|
||||||
|
# SQLite can't ALTER column nullability — rebuild the table if needed.
|
||||||
|
try:
|
||||||
|
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
|
||||||
|
cols = info.fetchall()
|
||||||
|
ip_col = next((c for c in cols if c[1] == "ip"), None)
|
||||||
|
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
|
||||||
|
if ip_col and ip_col[3] == 1:
|
||||||
|
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
|
||||||
|
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE pending_devices_new ("
|
||||||
|
"id VARCHAR PRIMARY KEY,"
|
||||||
|
"ip VARCHAR,"
|
||||||
|
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||||
|
"suggested_type VARCHAR,"
|
||||||
|
"status VARCHAR,"
|
||||||
|
"discovery_source VARCHAR,"
|
||||||
|
"ieee_address VARCHAR,"
|
||||||
|
"friendly_name VARCHAR,"
|
||||||
|
"device_subtype VARCHAR,"
|
||||||
|
"model VARCHAR,"
|
||||||
|
"vendor VARCHAR,"
|
||||||
|
"lqi INTEGER,"
|
||||||
|
"discovered_at DATETIME"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO pending_devices_new "
|
||||||
|
"(id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||||
|
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||||
|
"model, vendor, lqi, discovered_at) "
|
||||||
|
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||||
|
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||||
|
"model, vendor, lqi, discovered_at FROM pending_devices"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||||
|
"ON pending_devices(ieee_address)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||||
|
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
@@ -90,8 +275,8 @@ async def init_db() -> None:
|
|||||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||||
"FROM nodes WHERE properties IS NULL"
|
"FROM nodes WHERE properties IS NULL"
|
||||||
)
|
)
|
||||||
for row in rows.fetchall():
|
for r in rows.fetchall():
|
||||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
||||||
props = []
|
props = []
|
||||||
visible = bool(show_hardware)
|
visible = bool(show_hardware)
|
||||||
if cpu_model:
|
if cpu_model:
|
||||||
@@ -102,7 +287,6 @@ async def init_db() -> None:
|
|||||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||||
if disk_gb is not None:
|
if disk_gb is not None:
|
||||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||||
import json as _json
|
|
||||||
await conn.exec_driver_sql(
|
await conn.exec_driver_sql(
|
||||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||||
(_json.dumps(props), node_id),
|
(_json.dumps(props), node_id),
|
||||||
|
|||||||
@@ -16,12 +16,24 @@ def _uuid() -> str:
|
|||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
class Design(Base):
|
||||||
|
__tablename__ = "designs"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
|
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||||
|
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):
|
class Node(Base):
|
||||||
__tablename__ = "nodes"
|
__tablename__ = "nodes"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
ip: Mapped[str | None] = mapped_column(String)
|
ip: Mapped[str | None] = mapped_column(String)
|
||||||
mac: Mapped[str | None] = mapped_column(String)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -46,6 +58,7 @@ class Node(Base):
|
|||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||||
|
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
@@ -60,6 +73,7 @@ class Edge(Base):
|
|||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
|
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||||
label: Mapped[str | None] = mapped_column(String)
|
label: Mapped[str | None] = mapped_column(String)
|
||||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
@@ -76,8 +90,9 @@ class Edge(Base):
|
|||||||
class CanvasState(Base):
|
class CanvasState(Base):
|
||||||
__tablename__ = "canvas_state"
|
__tablename__ = "canvas_state"
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ class PendingDevice(Base):
|
|||||||
__tablename__ = "pending_devices"
|
__tablename__ = "pending_devices"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
ip: Mapped[str] = mapped_column(String, nullable=False)
|
ip: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
mac: Mapped[str | None] = mapped_column(String)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
os: Mapped[str | None] = mapped_column(String)
|
os: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -93,6 +108,31 @@ class PendingDevice(Base):
|
|||||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
discovery_source: Mapped[str | None] = mapped_column(String)
|
||||||
|
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
|
||||||
|
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
|
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeviceLink(Base):
|
||||||
|
"""Link between two Zigbee endpoints discovered during import.
|
||||||
|
|
||||||
|
Endpoints are addressed by IEEE (stable across re-imports). Either side may
|
||||||
|
already exist as a canvas Node (resolved via Node.ieee_address) or still be
|
||||||
|
a PendingDevice. On approval, the matching Edge is auto-created when both
|
||||||
|
endpoints exist as canvas Nodes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "pending_device_links"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
|
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||||
|
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||||
|
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -101,6 +141,7 @@ class ScanRun(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
status: Mapped[str] = mapped_column(String, default="running")
|
status: Mapped[str] = mapped_column(String, default="running")
|
||||||
|
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
|
||||||
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+4
-1
@@ -7,7 +7,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
|
||||||
from app.api.routes import settings as settings_routes
|
from app.api.routes import settings as settings_routes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
@@ -51,10 +51,13 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|||||||
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
|
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
||||||
|
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||||
|
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -62,9 +62,12 @@ class CanvasSaveRequest(BaseModel):
|
|||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
edges: list[EdgeSave] = []
|
edges: list[EdgeSave] = []
|
||||||
viewport: dict[str, Any] = {}
|
viewport: dict[str, Any] = {}
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class CanvasStateResponse(BaseModel):
|
class CanvasStateResponse(BaseModel):
|
||||||
nodes: list[NodeResponse]
|
nodes: list[NodeResponse]
|
||||||
edges: list[EdgeResponse]
|
edges: list[EdgeResponse]
|
||||||
viewport: dict[str, Any]
|
viewport: dict[str, Any]
|
||||||
|
custom_style: dict[str, Any] | None = None
|
||||||
|
|||||||
@@ -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):
|
class EdgeCreate(EdgeBase):
|
||||||
pass
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class EdgeUpdate(BaseModel):
|
class EdgeUpdate(BaseModel):
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class NodeBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
pass
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdate(BaseModel):
|
class NodeUpdate(BaseModel):
|
||||||
@@ -68,6 +68,8 @@ class NodeUpdate(BaseModel):
|
|||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
id: str
|
id: str
|
||||||
|
design_id: str | None = None
|
||||||
|
ieee_address: str | None = None
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
response_time_ms: int | None = None
|
response_time_ms: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
class PendingDeviceResponse(BaseModel):
|
class PendingDeviceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
ip: str
|
ip: str | None
|
||||||
mac: str | None
|
mac: str | None
|
||||||
hostname: str | None
|
hostname: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
@@ -14,6 +14,12 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
discovery_source: str | None
|
discovery_source: str | None
|
||||||
|
ieee_address: str | None = None
|
||||||
|
friendly_name: str | None = None
|
||||||
|
device_subtype: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
discovered_at: datetime
|
discovered_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
@@ -22,6 +28,7 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
class ScanRunResponse(BaseModel):
|
class ScanRunResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
|
kind: str = "ip"
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
devices_found: int
|
devices_found: int
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportRequest(BaseModel):
|
||||||
|
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
||||||
|
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
||||||
|
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
||||||
|
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
||||||
|
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
||||||
|
mqtt_tls_insecure: bool = Field(
|
||||||
|
False, description="Skip TLS certificate verification (self-signed only)"
|
||||||
|
)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeTestConnectionRequest(BaseModel):
|
||||||
|
mqtt_host: str
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||||
|
mqtt_username: str | None = None
|
||||||
|
mqtt_password: str | None = None
|
||||||
|
mqtt_tls: bool = False
|
||||||
|
mqtt_tls_insecure: bool = False
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
|
||||||
|
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||||
|
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeDeviceData(BaseModel):
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str # Coordinator, Router, EndDevice
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
last_seen: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeNodeOut(BaseModel):
|
||||||
|
"""A homelable-ready node representation of a Zigbee device."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
|
||||||
|
ieee_address: str
|
||||||
|
friendly_name: str
|
||||||
|
device_type: str
|
||||||
|
model: str | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
lqi: int | None = None
|
||||||
|
parent_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeEdgeOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportResponse(BaseModel):
|
||||||
|
nodes: list[ZigbeeNodeOut]
|
||||||
|
edges: list[ZigbeeEdgeOut]
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeTestConnectionResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeCoordinatorOut(BaseModel):
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
ieee_address: str
|
||||||
|
|
||||||
|
|
||||||
|
class ZigbeeImportPendingResponse(BaseModel):
|
||||||
|
"""Result of importing a Z2M network into the pending section."""
|
||||||
|
|
||||||
|
pending_created: int
|
||||||
|
pending_updated: int
|
||||||
|
coordinator: ZigbeeCoordinatorOut | None = None
|
||||||
|
coordinator_already_existed: bool = False
|
||||||
|
links_recorded: int
|
||||||
|
device_count: int
|
||||||
@@ -24,6 +24,11 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
host = target or raw_ip
|
host = target or raw_ip
|
||||||
if not host:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
|
||||||
|
# against arg-injection if a malicious admin sets target like "-O".
|
||||||
|
if host.startswith("-"):
|
||||||
|
logger.warning("Rejecting check target that starts with '-': %r", host)
|
||||||
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
@@ -60,8 +65,14 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
# ping(8) -W flag units differ by OS:
|
||||||
|
# Linux: seconds (-W 1 = 1s)
|
||||||
|
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
||||||
|
# Windows: -w in ms (-w 1000 = 1s)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
args = ["ping", "-c", "1", "-W", "1", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import ssl
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiomqtt
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
aiomqtt = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||||
|
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||||
|
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||||
|
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||||
|
"""Return a generic, credential-free message for an MQTT error.
|
||||||
|
|
||||||
|
The raw aiomqtt/paho error string can include the broker URI with
|
||||||
|
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||||
|
detail that should not leak to API clients. Map known patterns to
|
||||||
|
coarse categories; default to a generic failure message. The original
|
||||||
|
exception is logged at WARNING level for operator debugging.
|
||||||
|
"""
|
||||||
|
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||||
|
raw = str(exc).lower()
|
||||||
|
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||||
|
return "Authentication failed"
|
||||||
|
if "refused" in raw:
|
||||||
|
return "Connection refused by broker"
|
||||||
|
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||||
|
return "Broker hostname could not be resolved"
|
||||||
|
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||||
|
return "TLS handshake failed"
|
||||||
|
if "timed out" in raw or "timeout" in raw:
|
||||||
|
return "Connection to broker timed out"
|
||||||
|
return "MQTT connection failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||||
|
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
if insecure:
|
||||||
|
logger.warning(
|
||||||
|
"MQTT TLS certificate verification is DISABLED — "
|
||||||
|
"use only with self-signed brokers on trusted networks."
|
||||||
|
)
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
def build_zigbee_properties(
|
||||||
|
ieee: str | None,
|
||||||
|
vendor: str | None,
|
||||||
|
model: str | None,
|
||||||
|
lqi: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
|
||||||
|
|
||||||
|
Only includes a row when the value is non-empty. Shape matches the
|
||||||
|
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
||||||
|
|
||||||
|
New props default to ``visible=False`` — users opt in to showing them on
|
||||||
|
the canvas card from the right panel.
|
||||||
|
"""
|
||||||
|
props: list[dict[str, Any]] = []
|
||||||
|
if ieee:
|
||||||
|
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
||||||
|
if vendor:
|
||||||
|
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||||
|
if model:
|
||||||
|
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||||
|
if lqi is not None:
|
||||||
|
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def merge_zigbee_properties(
|
||||||
|
existing: list[dict[str, Any]] | None,
|
||||||
|
new_props: list[dict[str, Any]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Merge fresh zigbee props into an existing property list.
|
||||||
|
|
||||||
|
For keys already present: update ``value`` but preserve the user's
|
||||||
|
``visible`` choice. New keys are appended with whatever visibility the
|
||||||
|
caller gave them (hidden by default per ``build_zigbee_properties``).
|
||||||
|
Non-zigbee custom properties are preserved untouched.
|
||||||
|
"""
|
||||||
|
out = [dict(p) for p in (existing or [])]
|
||||||
|
by_key = {p.get("key"): p for p in out}
|
||||||
|
for np in new_props:
|
||||||
|
key = np.get("key")
|
||||||
|
if key in by_key:
|
||||||
|
by_key[key]["value"] = np.get("value")
|
||||||
|
else:
|
||||||
|
out.append(dict(np))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||||
|
"""Map a Z2M device type string to a homelable node type."""
|
||||||
|
mapping = {
|
||||||
|
"Coordinator": "zigbee_coordinator",
|
||||||
|
"Router": "zigbee_router",
|
||||||
|
"EndDevice": "zigbee_enddevice",
|
||||||
|
}
|
||||||
|
return mapping.get(device_type, "zigbee_enddevice")
|
||||||
|
|
||||||
|
|
||||||
|
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
|
||||||
|
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
|
||||||
|
if not ieee:
|
||||||
|
return None
|
||||||
|
device_type: str = raw.get("type") or "EndDevice"
|
||||||
|
friendly_name: str = (
|
||||||
|
raw.get("friendlyName") or raw.get("friendly_name") or ieee
|
||||||
|
)
|
||||||
|
definition: dict[str, Any] = raw.get("definition") or {}
|
||||||
|
model: str | None = (
|
||||||
|
raw.get("modelID")
|
||||||
|
or raw.get("model")
|
||||||
|
or definition.get("model")
|
||||||
|
or None
|
||||||
|
)
|
||||||
|
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": friendly_name,
|
||||||
|
"type": _z2m_type_to_homelable(device_type),
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"friendly_name": friendly_name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"model": model,
|
||||||
|
"vendor": vendor,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_networkmap(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
|
||||||
|
|
||||||
|
Z2M raw response shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": false,
|
||||||
|
"value": {
|
||||||
|
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
|
||||||
|
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
|
||||||
|
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
|
||||||
|
"lqi": 200, "depth": 1}]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
Older or alternate shapes may put nodes/links directly under ``data``.
|
||||||
|
Both are accepted.
|
||||||
|
"""
|
||||||
|
data: dict[str, Any] = payload.get("data") or {}
|
||||||
|
value = data.get("value")
|
||||||
|
container: dict[str, Any] = value if isinstance(value, dict) else data
|
||||||
|
|
||||||
|
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
|
||||||
|
raw_links: list[dict[str, Any]] = container.get("links") or []
|
||||||
|
|
||||||
|
if not isinstance(raw_nodes, list):
|
||||||
|
raise ValueError("Malformed networkmap: 'nodes' is not a list")
|
||||||
|
if not isinstance(raw_links, list):
|
||||||
|
raise ValueError("Malformed networkmap: 'links' is not a list")
|
||||||
|
|
||||||
|
nodes_list: list[dict[str, Any]] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
coordinator_id: str | None = None
|
||||||
|
|
||||||
|
for entry in raw_nodes:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
node = _node_from_z2m(entry)
|
||||||
|
if node is None or node["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(node["id"])
|
||||||
|
nodes_list.append(node)
|
||||||
|
if node["device_type"] == "Coordinator":
|
||||||
|
coordinator_id = node["id"]
|
||||||
|
|
||||||
|
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
|
||||||
|
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
|
||||||
|
# resolve which router an end device hangs off; do NOT emit edges directly
|
||||||
|
# from links. The final edge set is the strict parent→child tree built
|
||||||
|
# from parent_id below — that avoids duplicate edges and keeps the visual
|
||||||
|
# flow consistent (parent bottom → child top).
|
||||||
|
raw_edges: list[dict[str, Any]] = []
|
||||||
|
lqi_by_id: dict[str, int] = {}
|
||||||
|
|
||||||
|
for link in raw_links:
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
continue
|
||||||
|
src_obj = link.get("source") or {}
|
||||||
|
tgt_obj = link.get("target") or {}
|
||||||
|
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
|
||||||
|
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
|
||||||
|
if not src or not tgt:
|
||||||
|
continue
|
||||||
|
if src not in seen_ids or tgt not in seen_ids:
|
||||||
|
continue
|
||||||
|
raw_edges.append({"source": src, "target": tgt})
|
||||||
|
lqi = link.get("lqi") or link.get("linkquality")
|
||||||
|
if isinstance(lqi, int) and tgt not in lqi_by_id:
|
||||||
|
lqi_by_id[tgt] = lqi
|
||||||
|
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["id"] in lqi_by_id:
|
||||||
|
node["lqi"] = lqi_by_id[node["id"]]
|
||||||
|
|
||||||
|
# Build parent_id hierarchy: coordinator → routers → end devices
|
||||||
|
if coordinator_id:
|
||||||
|
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
||||||
|
for node in nodes_list:
|
||||||
|
if node["device_type"] == "Router":
|
||||||
|
node["parent_id"] = coordinator_id
|
||||||
|
elif node["device_type"] == "EndDevice":
|
||||||
|
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||||
|
node["parent_id"] = parent or coordinator_id
|
||||||
|
|
||||||
|
# Final edges = strict parent → child tree (one edge per non-coordinator)
|
||||||
|
edges_list: list[dict[str, Any]] = [
|
||||||
|
{"source": node["parent_id"], "target": node["id"]}
|
||||||
|
for node in nodes_list
|
||||||
|
if node.get("parent_id")
|
||||||
|
]
|
||||||
|
|
||||||
|
return nodes_list, edges_list
|
||||||
|
|
||||||
|
|
||||||
|
def _find_parent_router(
|
||||||
|
device_id: str,
|
||||||
|
router_ids: set[str],
|
||||||
|
edges: list[dict[str, Any]],
|
||||||
|
) -> str | None:
|
||||||
|
"""Return the first router that has a direct edge to device_id."""
|
||||||
|
for edge in edges:
|
||||||
|
src: str = edge["source"]
|
||||||
|
tgt: str = edge["target"]
|
||||||
|
if tgt == device_id and src in router_ids:
|
||||||
|
return src
|
||||||
|
if src == device_id and tgt in router_ids:
|
||||||
|
return tgt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_networkmap(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
base_topic: str,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TimeoutError: if the broker does not respond in time.
|
||||||
|
ConnectionError: if the broker cannot be reached.
|
||||||
|
ValueError: if the response payload is malformed.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"aiomqtt is required for Zigbee import. "
|
||||||
|
"Install it with: pip install aiomqtt"
|
||||||
|
)
|
||||||
|
|
||||||
|
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||||
|
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||||
|
|
||||||
|
response_payload: dict[str, Any] = {}
|
||||||
|
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
) as client:
|
||||||
|
await client.subscribe(response_topic)
|
||||||
|
# Give the broker a brief window to register the subscription
|
||||||
|
# before we publish the request. Without this, brokers that
|
||||||
|
# race SUBACK with our PUBLISH may deliver the response before
|
||||||
|
# the subscription is active and we'd hang until timeout.
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
await client.publish(
|
||||||
|
request_topic,
|
||||||
|
json.dumps({"type": "raw", "routes": False}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _wait_for_response() -> None:
|
||||||
|
async for message in client.messages:
|
||||||
|
if str(message.topic) != response_topic:
|
||||||
|
continue
|
||||||
|
raw = message.payload
|
||||||
|
try:
|
||||||
|
payload_str = (
|
||||||
|
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||||
|
)
|
||||||
|
response_payload.update(json.loads(payload_str))
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Malformed networkmap response: {exc}"
|
||||||
|
) from exc
|
||||||
|
return
|
||||||
|
|
||||||
|
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||||
|
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
||||||
|
|
||||||
|
if not response_payload:
|
||||||
|
raise ValueError("Empty networkmap response received")
|
||||||
|
|
||||||
|
return parse_networkmap(response_payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_mqtt_connection(
|
||||||
|
mqtt_host: str,
|
||||||
|
mqtt_port: int,
|
||||||
|
username: str | None = None,
|
||||||
|
password: str | None = None,
|
||||||
|
tls: bool = False,
|
||||||
|
tls_insecure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||||
|
|
||||||
|
Returns True on success, raises ConnectionError on failure.
|
||||||
|
"""
|
||||||
|
if aiomqtt is None: # pragma: no cover
|
||||||
|
raise ImportError("aiomqtt is required")
|
||||||
|
|
||||||
|
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiomqtt.Client(
|
||||||
|
hostname=mqtt_host,
|
||||||
|
port=mqtt_port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
timeout=_CONNECTION_TIMEOUT,
|
||||||
|
tls_context=tls_context,
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
except aiomqtt.MqttError as exc:
|
||||||
|
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||||
|
except asyncio.TimeoutError as exc:
|
||||||
|
raise TimeoutError("Connection to broker timed out") from exc
|
||||||
@@ -7,16 +7,16 @@ alembic==1.13.3
|
|||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
passlib[bcrypt]==1.7.4
|
bcrypt==4.2.1
|
||||||
bcrypt==4.0.1
|
python-multipart==0.0.27
|
||||||
python-multipart==0.0.26
|
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.131.0
|
zeroconf==0.149.7
|
||||||
|
aiomqtt==2.3.0
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""Generate a bcrypt password hash for config.yml."""
|
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from passlib.context import CryptContext
|
import bcrypt
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("Usage: python scripts/hash_password.py <password>")
|
print("Usage: python scripts/hash_password.py <password>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
password = sys.argv[1]
|
password = sys.argv[1]
|
||||||
print(pwd_context.hash(password))
|
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
|
||||||
|
|||||||
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.security import hash_password
|
||||||
from app.db.database import Base, get_db
|
from app.db.database import Base, get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def test_credentials():
|
def test_credentials():
|
||||||
"""Configure test auth credentials directly on settings."""
|
"""Configure test auth credentials directly on settings."""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
settings.auth_username = "admin"
|
settings.auth_username = "admin"
|
||||||
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
settings.auth_password_hash = hash_password("admin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
|
|||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
finally:
|
finally:
|
||||||
settings.auth_password_hash = original
|
settings.auth_password_hash = original
|
||||||
|
|
||||||
|
|
||||||
|
# --- JWT-level cases ---
|
||||||
|
|
||||||
|
async def test_expired_token_rejected(client: AsyncClient):
|
||||||
|
"""A JWT whose `exp` is in the past must be refused."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
payload = {
|
||||||
|
"sub": "admin",
|
||||||
|
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_malformed_token_rejected(client: AsyncClient):
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||||
|
"""A token signed with a different key must not be accepted."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from jose import jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
payload = {
|
||||||
|
"sub": "admin",
|
||||||
|
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
||||||
|
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_missing_authorization_header_rejected(client: AsyncClient):
|
||||||
|
res = await client.get("/api/v1/nodes")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
|
||||||
|
"""No credentials configured server-side must not authenticate an empty password."""
|
||||||
|
from app.core.config import settings
|
||||||
|
original_hash = settings.auth_password_hash
|
||||||
|
settings.auth_password_hash = ""
|
||||||
|
try:
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
|
||||||
|
assert res.status_code == 401
|
||||||
|
finally:
|
||||||
|
settings.auth_password_hash = original_hash
|
||||||
|
|
||||||
|
|
||||||
|
# --- Password helper ---
|
||||||
|
|
||||||
|
def test_verify_password_handles_empty_inputs():
|
||||||
|
"""verify_password must be safe against empty plain / empty hash without raising."""
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
h = hash_password("hunter2")
|
||||||
|
assert verify_password("hunter2", h) is True
|
||||||
|
assert verify_password("", h) is False
|
||||||
|
assert verify_password("hunter2", "") is False
|
||||||
|
assert verify_password("", "") is False
|
||||||
|
|||||||
@@ -557,3 +557,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
|
|||||||
edge = canvas["edges"][0]
|
edge = canvas["edges"][0]
|
||||||
assert edge["label"] == "updated"
|
assert edge["label"] == "updated"
|
||||||
assert edge["custom_color"] == "#ff0000"
|
assert edge["custom_color"] == "#ff0000"
|
||||||
|
|
||||||
|
|
||||||
|
# ── custom_style ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
||||||
|
custom_style = {
|
||||||
|
"nodes": {
|
||||||
|
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
||||||
|
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["custom_style"] is not None
|
||||||
|
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
||||||
|
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
||||||
|
res = await client.get("/api/v1/canvas", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["custom_style"] is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
||||||
|
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||||
|
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||||
|
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert "proxmox" in canvas["custom_style"]["nodes"]
|
||||||
|
assert "server" not in canvas["custom_style"]["nodes"]
|
||||||
|
|||||||
@@ -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"
|
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 ─────────────────────────────────────────────────
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -37,6 +37,95 @@ async def pending_device(db_session):
|
|||||||
return device
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# --- _background_scan error handling ---
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def mem_db():
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.db.database import Base
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
yield factory
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||||
|
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||||
|
session rollback path must execute without a follow-on exception."""
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch(
|
||||||
|
"app.api.routes.scan.run_scan",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
refreshed = await session.get(ScanRun, run_id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.status == "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||||
|
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||||
|
must NOT overwrite that terminal status with 'failed'."""
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch(
|
||||||
|
"app.api.routes.scan.run_scan",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("boom"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
refreshed = await session.get(ScanRun, run_id)
|
||||||
|
assert refreshed is not None
|
||||||
|
assert refreshed.status == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||||
|
from app.api.routes.scan import _background_scan
|
||||||
|
|
||||||
|
async with mem_db() as session:
|
||||||
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
run_id = run.id
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||||
|
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||||
|
):
|
||||||
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||||
|
mock_run_scan.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
# --- Trigger scan ---
|
# --- Trigger scan ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -140,6 +229,49 @@ async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|||||||
assert len(hidden_res.json()) == 1
|
assert len(hidden_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Restore hidden device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide first
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] is True
|
||||||
|
|
||||||
|
# Now back in pending, gone from hidden
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert hidden_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||||
|
assert res.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||||
|
# Hide
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-restore",
|
||||||
|
headers=headers,
|
||||||
|
json={"device_ids": [pending_device.id]},
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["restored"] == 1
|
||||||
|
assert res.json()["skipped"] == 0
|
||||||
|
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert len(pending_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
# --- Ignore device ---
|
# --- Ignore device ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -485,6 +617,118 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
|||||||
assert pending_res.json() == []
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def zigbee_pending_device(db_session):
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip=None,
|
||||||
|
mac=None,
|
||||||
|
hostname=None,
|
||||||
|
friendly_name="bulb_1",
|
||||||
|
services=[],
|
||||||
|
suggested_type="zigbee_enddevice",
|
||||||
|
device_subtype="EndDevice",
|
||||||
|
ieee_address="0xABCDEF",
|
||||||
|
vendor="IKEA",
|
||||||
|
model="TRADFRI",
|
||||||
|
lqi=180,
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(device)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_device_populates_properties(
|
||||||
|
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||||
|
):
|
||||||
|
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
payload = {
|
||||||
|
"label": "bulb_1",
|
||||||
|
"type": "zigbee_enddevice",
|
||||||
|
"status": "online",
|
||||||
|
"services": [],
|
||||||
|
"check_method": "none",
|
||||||
|
}
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in node.properties}
|
||||||
|
assert keys == {
|
||||||
|
"IEEE": "0xABCDEF",
|
||||||
|
"Vendor": "IKEA",
|
||||||
|
"Model": "TRADFRI",
|
||||||
|
"LQI": "180",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_zigbee_populates_properties(
|
||||||
|
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": [zigbee_pending_device.id]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in node.properties}
|
||||||
|
assert keys["IEEE"] == "0xABCDEF"
|
||||||
|
assert keys["Vendor"] == "IKEA"
|
||||||
|
assert keys["Model"] == "TRADFRI"
|
||||||
|
assert keys["LQI"] == "180"
|
||||||
|
assert node.check_method == "none"
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
ids = [d.id for d in two_pending_devices]
|
||||||
|
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||||
|
for n in nodes:
|
||||||
|
if n.ip:
|
||||||
|
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
||||||
|
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": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||||
|
assert node is not None
|
||||||
|
assert node.check_method == "ping"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||||
ids = [d.id for d in two_pending_devices]
|
ids = [d.id for d in two_pending_devices]
|
||||||
@@ -542,3 +786,201 @@ async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices)
|
|||||||
ids = [d.id for d in two_pending_devices]
|
ids = [d.id for d in two_pending_devices]
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_zigbee_pending_pair(db_session):
|
||||||
|
"""Create a coordinator Node + a pending device + a link between them."""
|
||||||
|
from app.db.models import Node, PendingDevice, PendingDeviceLink
|
||||||
|
|
||||||
|
coord = Node(
|
||||||
|
label="Coordinator",
|
||||||
|
type="zigbee_coordinator",
|
||||||
|
status="unknown",
|
||||||
|
ieee_address="0xCOORD",
|
||||||
|
)
|
||||||
|
db_session.add(coord)
|
||||||
|
|
||||||
|
pending = PendingDevice(
|
||||||
|
ieee_address="0xR1",
|
||||||
|
friendly_name="router_1",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(pending)
|
||||||
|
|
||||||
|
db_session.add(
|
||||||
|
PendingDeviceLink(
|
||||||
|
source_ieee="0xCOORD",
|
||||||
|
target_ieee="0xR1",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
return coord, pending
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Edge
|
||||||
|
|
||||||
|
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending.id}/approve",
|
||||||
|
json={
|
||||||
|
"label": "router_1",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ip": None,
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] is True
|
||||||
|
assert data["edges_created"] == 1
|
||||||
|
|
||||||
|
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||||
|
assert len(edges) == 1
|
||||||
|
assert edges[0].source == coord.id
|
||||||
|
assert edges[0].target == data["node_id"]
|
||||||
|
assert edges[0].source_handle == "bottom"
|
||||||
|
assert edges[0].target_handle == "top-t"
|
||||||
|
assert edges[0].type == "iot"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_skips_duplicate_edge(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""Re-running the resolution does not create a second edge for the same pair."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||||
|
|
||||||
|
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||||
|
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||||
|
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
|
||||||
|
|
||||||
|
# Simulate a second pending row + link between same coord and a new device,
|
||||||
|
# but keep an existing edge in place to verify dedupe also handles
|
||||||
|
# the swapped-direction case.
|
||||||
|
new_pending = PendingDevice(
|
||||||
|
ieee_address="0xR1B",
|
||||||
|
friendly_name="r1b",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(new_pending)
|
||||||
|
db_session.add(
|
||||||
|
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
|
||||||
|
)
|
||||||
|
assert res.json()["edges_created"] == 1 # only the new pair
|
||||||
|
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||||
|
assert len(edges) == 2 # original + new, no duplicate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||||
|
|
||||||
|
a = PendingDevice(
|
||||||
|
ieee_address="0xA",
|
||||||
|
friendly_name="a",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
b = PendingDevice(
|
||||||
|
ieee_address="0xB",
|
||||||
|
friendly_name="b",
|
||||||
|
suggested_type="zigbee_enddevice",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add_all([a, b])
|
||||||
|
db_session.add(
|
||||||
|
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{a.id}/approve",
|
||||||
|
json={
|
||||||
|
"label": "a",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ip": None,
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["edges_created"] == 0
|
||||||
|
|
||||||
|
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||||
|
assert edges == []
|
||||||
|
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
|
assert len(links) == 1 # preserved for later resolution
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""First approval keeps link; second approval creates the edge."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||||
|
|
||||||
|
a = PendingDevice(
|
||||||
|
ieee_address="0xA",
|
||||||
|
friendly_name="a",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
b = PendingDevice(
|
||||||
|
ieee_address="0xB",
|
||||||
|
friendly_name="b",
|
||||||
|
suggested_type="zigbee_enddevice",
|
||||||
|
status="pending",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add_all([a, b])
|
||||||
|
db_session.add(
|
||||||
|
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||||
|
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
|
||||||
|
assert res.json()["edges_created"] == 1
|
||||||
|
|
||||||
|
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||||
|
assert len(edges) == 1
|
||||||
|
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
|
assert links == [] # consumed
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_homepage_key():
|
||||||
|
original = settings.homepage_api_key
|
||||||
|
settings.homepage_api_key = ""
|
||||||
|
yield
|
||||||
|
settings.homepage_api_key = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
||||||
|
res = await client.get("/api/v1/stats/summary")
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert "disabled" in res.json()["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get("/api/v1/stats/summary")
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_empty_db(client: AsyncClient) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body == {
|
||||||
|
"nodes": 0,
|
||||||
|
"online": 0,
|
||||||
|
"offline": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
"pending_devices": 0,
|
||||||
|
"zigbee_devices": 0,
|
||||||
|
"last_scan_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_summary_aggregates_counts(
|
||||||
|
client: AsyncClient, db_session: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
settings.homepage_api_key = "topsecret"
|
||||||
|
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||||
|
db_session.add_all([
|
||||||
|
Node(type="server", label="A", status="online"),
|
||||||
|
Node(type="server", label="B", status="online"),
|
||||||
|
Node(type="server", label="C", status="offline"),
|
||||||
|
Node(type="server", label="D", status="unknown"),
|
||||||
|
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
||||||
|
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
||||||
|
PendingDevice(ip="10.0.0.1", status="pending"),
|
||||||
|
PendingDevice(ip="10.0.0.2", status="pending"),
|
||||||
|
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
||||||
|
ScanRun(status="success", finished_at=finished),
|
||||||
|
ScanRun(status="success",
|
||||||
|
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
||||||
|
])
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.get(
|
||||||
|
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["nodes"] == 6
|
||||||
|
assert body["online"] == 4
|
||||||
|
assert body["offline"] == 1
|
||||||
|
assert body["unknown"] == 1
|
||||||
|
assert body["pending_devices"] == 2
|
||||||
|
assert body["zigbee_devices"] == 2
|
||||||
|
# SQLite returns naive datetimes; compare prefix only.
|
||||||
|
assert body["last_scan_at"] is not None
|
||||||
|
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
||||||
@@ -169,6 +169,31 @@ async def test_ping_uses_unix_args_on_non_windows():
|
|||||||
assert "-c" in captured["args"]
|
assert "-c" in captured["args"]
|
||||||
assert "-W" in captured["args"]
|
assert "-W" in captured["args"]
|
||||||
assert "-n" not in captured["args"]
|
assert "-n" not in captured["args"]
|
||||||
|
# Linux: -W is in seconds; 1s is the intended timeout
|
||||||
|
w_idx = captured["args"].index("-W")
|
||||||
|
assert captured["args"][w_idx + 1] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_uses_macos_millisecond_timeout():
|
||||||
|
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("192.168.1.1")
|
||||||
|
|
||||||
|
assert "-c" in captured["args"]
|
||||||
|
assert "-W" in captured["args"]
|
||||||
|
w_idx = captured["args"].index("-W")
|
||||||
|
assert captured["args"][w_idx + 1] == "1000"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -191,6 +216,31 @@ async def test_ping_uses_windows_args_on_win32():
|
|||||||
assert "-c" not in captured["args"]
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_node target validation ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_node_rejects_flag_like_target():
|
||||||
|
"""A target starting with '-' must never reach subprocess invocation."""
|
||||||
|
from app.services.status_checker import check_node
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||||
|
result = await check_node("ping", "-O", None)
|
||||||
|
|
||||||
|
mock_exec.assert_not_called()
|
||||||
|
assert result["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_node_rejects_flag_like_ip():
|
||||||
|
from app.services.status_checker import check_node
|
||||||
|
|
||||||
|
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
||||||
|
result = await check_node("ping", None, "-O")
|
||||||
|
|
||||||
|
mock_exec.assert_not_called()
|
||||||
|
assert result["status"] == "unknown"
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
"""API endpoint tests for /api/v1/zigbee/*."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/test-connection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is True
|
||||||
|
assert "success" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["connected"] is False
|
||||||
|
assert "refused" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422 # pydantic validation error
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/import
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SAMPLE_NODES = [
|
||||||
|
{
|
||||||
|
"id": "0x00000000",
|
||||||
|
"label": "Coordinator",
|
||||||
|
"type": "zigbee_coordinator",
|
||||||
|
"ieee_address": "0x00000000",
|
||||||
|
"friendly_name": "Coordinator",
|
||||||
|
"device_type": "Coordinator",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0x00000001",
|
||||||
|
"label": "router_1",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ieee_address": "0x00000001",
|
||||||
|
"friendly_name": "router_1",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "CC2530",
|
||||||
|
"vendor": "Texas Instruments",
|
||||||
|
"lqi": 230,
|
||||||
|
"parent_id": "0x00000000",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_SAMPLE_EDGES = [
|
||||||
|
{"source": "0x00000000", "target": "0x00000001"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"base_topic": "zigbee2mqtt",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 2
|
||||||
|
assert len(data["nodes"]) == 2
|
||||||
|
assert len(data["edges"]) == 1
|
||||||
|
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
|
||||||
|
assert coordinator["ieee_address"] == "0x00000000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "localhost",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_username": "admin",
|
||||||
|
"mqtt_password": "secret",
|
||||||
|
"base_topic": "z2m",
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
mock_fetch.assert_called_once_with(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="z2m",
|
||||||
|
username="admin",
|
||||||
|
password="secret",
|
||||||
|
tls=False,
|
||||||
|
tls_insecure=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 502
|
||||||
|
assert "broker unreachable" in res.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = TimeoutError("timed out")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 504
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.side_effect = ValueError("malformed response")
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
||||||
|
"""An empty Zigbee network (coordinator only) is a valid response."""
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["device_count"] == 0
|
||||||
|
assert data["nodes"] == []
|
||||||
|
assert data["edges"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={"mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||||
|
mock_fetch.return_value = ([], [])
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_tls": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
kwargs = mock_fetch.call_args.kwargs
|
||||||
|
assert kwargs["tls"] is True
|
||||||
|
assert kwargs["tls_insecure"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 1883,
|
||||||
|
"mqtt_tls": False,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /api/v1/zigbee/import-pending
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PENDING_NODES = [
|
||||||
|
{
|
||||||
|
"id": "0xCOORD",
|
||||||
|
"label": "Coordinator",
|
||||||
|
"type": "zigbee_coordinator",
|
||||||
|
"ieee_address": "0xCOORD",
|
||||||
|
"friendly_name": "Coordinator",
|
||||||
|
"device_type": "Coordinator",
|
||||||
|
"model": None,
|
||||||
|
"vendor": None,
|
||||||
|
"lqi": None,
|
||||||
|
"parent_id": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0xR1",
|
||||||
|
"label": "router_1",
|
||||||
|
"type": "zigbee_router",
|
||||||
|
"ieee_address": "0xR1",
|
||||||
|
"friendly_name": "router_1",
|
||||||
|
"device_type": "Router",
|
||||||
|
"model": "CC2530",
|
||||||
|
"vendor": "TI",
|
||||||
|
"lqi": 220,
|
||||||
|
"parent_id": "0xCOORD",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "0xE1",
|
||||||
|
"label": "bulb_kitchen",
|
||||||
|
"type": "zigbee_enddevice",
|
||||||
|
"ieee_address": "0xE1",
|
||||||
|
"friendly_name": "bulb_kitchen",
|
||||||
|
"device_type": "EndDevice",
|
||||||
|
"model": "TRADFRI",
|
||||||
|
"vendor": "IKEA",
|
||||||
|
"lqi": 180,
|
||||||
|
"parent_id": "0xR1",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
_PENDING_EDGES = [
|
||||||
|
{"source": "0xCOORD", "target": "0xR1"},
|
||||||
|
{"source": "0xR1", "target": "0xE1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
||||||
|
client: AsyncClient, headers: dict
|
||||||
|
) -> None:
|
||||||
|
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
|
||||||
|
the actual networkmap fetch + pending persist runs in the background."""
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.api.routes.zigbee._background_zigbee_import",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
run = res.json()
|
||||||
|
assert run["kind"] == "zigbee"
|
||||||
|
assert run["status"] == "running"
|
||||||
|
assert run["ranges"] == ["localhost:1883"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_creates_coordinator_and_pending(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
assert result.device_count == 3
|
||||||
|
assert result.pending_created == 2
|
||||||
|
assert result.pending_updated == 0
|
||||||
|
assert result.coordinator is not None
|
||||||
|
assert result.coordinator.ieee_address == "0xCOORD"
|
||||||
|
assert result.coordinator_already_existed is False
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_idempotent_updates_existing(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 99
|
||||||
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
assert result.pending_created == 0
|
||||||
|
assert result.pending_updated == 2
|
||||||
|
assert result.coordinator_already_existed is True
|
||||||
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_replaces_links(db_session) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDeviceLink
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
||||||
|
|
||||||
|
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
|
||||||
|
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||||
|
nodes_with_meta[0]["vendor"] = "TI"
|
||||||
|
nodes_with_meta[0]["model"] = "CC2652"
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||||
|
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
||||||
|
# New zigbee props default to hidden — user opts in from the right panel.
|
||||||
|
assert all(p["visible"] is False for p in coord.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""A device already approved as a canvas Node must not reappear in pending.
|
||||||
|
|
||||||
|
Its properties must still be refreshed with the latest Vendor/Model/LQI.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
|
# Simulate: router was approved earlier → exists as a canvas Node.
|
||||||
|
approved = Node(
|
||||||
|
label="router_1",
|
||||||
|
type="zigbee_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="0xR1",
|
||||||
|
services=[],
|
||||||
|
properties=[],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 250 # new LQI from re-import
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
# No PendingDevice row was created for the approved router.
|
||||||
|
pendings = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert pendings == []
|
||||||
|
|
||||||
|
# Node properties got refreshed.
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||||
|
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
||||||
|
# Brand-new props on an existing Node start hidden.
|
||||||
|
assert all(p["visible"] is False for p in refreshed.properties)
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
approved = Node(
|
||||||
|
label="router_1",
|
||||||
|
type="zigbee_router",
|
||||||
|
status="online",
|
||||||
|
check_method="none",
|
||||||
|
ieee_address="0xR1",
|
||||||
|
services=[],
|
||||||
|
properties=[
|
||||||
|
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
|
||||||
|
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
|
||||||
|
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
db_session.add(approved)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[1]["lqi"] = 99
|
||||||
|
bumped[1]["model"] = "CC2530"
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
refreshed = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||||
|
).scalar_one()
|
||||||
|
by_key = {p["key"]: p for p in refreshed.properties}
|
||||||
|
# Existing keys keep their visibility (True).
|
||||||
|
assert by_key["IEEE"]["visible"] is True
|
||||||
|
assert by_key["Vendor"]["visible"] is True
|
||||||
|
# New key arrives hidden.
|
||||||
|
assert by_key["Model"]["visible"] is False
|
||||||
|
assert by_key["LQI"]["visible"] is False
|
||||||
|
assert by_key["LQI"]["value"] == "99"
|
||||||
|
# Non-zigbee user-added prop is preserved untouched.
|
||||||
|
assert by_key["Custom"]["value"] == "kept"
|
||||||
|
assert by_key["Custom"]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import Node
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
bumped = [dict(n) for n in _PENDING_NODES]
|
||||||
|
bumped[0]["vendor"] = "TI"
|
||||||
|
bumped[0]["model"] = "CC2652"
|
||||||
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
|
coord = (
|
||||||
|
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||||
|
).scalar_one()
|
||||||
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
|
assert keys["Vendor"] == "TI"
|
||||||
|
assert keys["Model"] == "CC2652"
|
||||||
|
# Newly added keys on re-import default to hidden.
|
||||||
|
by_key = {p["key"]: p for p in coord.properties}
|
||||||
|
assert by_key["Vendor"]["visible"] is False
|
||||||
|
assert by_key["Model"]["visible"] is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/import-pending",
|
||||||
|
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||||
|
)
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
|
||||||
|
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
||||||
|
mock_conn.return_value = True
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/zigbee/test-connection",
|
||||||
|
json={
|
||||||
|
"mqtt_host": "broker.example.com",
|
||||||
|
"mqtt_port": 8883,
|
||||||
|
"mqtt_tls": True,
|
||||||
|
"mqtt_tls_insecure": True,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
kwargs = mock_conn.call_args.kwargs
|
||||||
|
assert kwargs["tls"] is True
|
||||||
|
assert kwargs["tls_insecure"] is True
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
"""Unit tests for zigbee_service: parser and hierarchy builder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import aiomqtt # noqa: F401
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
_find_parent_router,
|
||||||
|
_z2m_type_to_homelable,
|
||||||
|
fetch_networkmap,
|
||||||
|
parse_networkmap,
|
||||||
|
)
|
||||||
|
from app.services.zigbee_service import (
|
||||||
|
test_mqtt_connection as _test_mqtt_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper builders — real Z2M `bridge/response/networkmap` shape
|
||||||
|
# (data.value.nodes + data.value.links)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_node(
|
||||||
|
ieee: str,
|
||||||
|
device_type: str = "EndDevice",
|
||||||
|
friendly_name: str | None = None,
|
||||||
|
model: str | None = None,
|
||||||
|
vendor: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
entry: dict[str, Any] = {
|
||||||
|
"ieeeAddr": ieee,
|
||||||
|
"type": device_type,
|
||||||
|
"friendlyName": friendly_name or ieee,
|
||||||
|
}
|
||||||
|
if model or vendor:
|
||||||
|
entry["definition"] = {"model": model, "vendor": vendor}
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"source": {"ieeeAddr": source_ieee},
|
||||||
|
"target": {"ieeeAddr": target_ieee},
|
||||||
|
"lqi": lqi,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": False,
|
||||||
|
"value": {"nodes": nodes, "links": links or []},
|
||||||
|
},
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _z2m_type_to_homelable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestZ2mTypeToHomelable:
|
||||||
|
def test_coordinator(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
|
||||||
|
|
||||||
|
def test_router(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Router") == "zigbee_router"
|
||||||
|
|
||||||
|
def test_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
def test_unknown_defaults_to_enddevice(self) -> None:
|
||||||
|
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# parse_networkmap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestParseNetworkmap:
|
||||||
|
def test_empty_payload(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap({})
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_empty_value(self) -> None:
|
||||||
|
nodes, edges = parse_networkmap(_wrap([], []))
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_only(self) -> None:
|
||||||
|
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||||
|
assert nodes[0]["ieee_address"] == "0x0000000000000000"
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_coordinator_router_enddevice(self) -> None:
|
||||||
|
coord_ieee = "0x0000000000000000"
|
||||||
|
router_ieee = "0x0000000000000001"
|
||||||
|
end_ieee = "0x0000000000000002"
|
||||||
|
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[
|
||||||
|
_make_node(coord_ieee, "Coordinator", "Coordinator"),
|
||||||
|
_make_node(router_ieee, "Router", "my_router"),
|
||||||
|
_make_node(end_ieee, "EndDevice"),
|
||||||
|
],
|
||||||
|
links=[
|
||||||
|
_make_link(coord_ieee, router_ieee),
|
||||||
|
_make_link(router_ieee, end_ieee),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
node_by_id = {n["id"]: n for n in nodes}
|
||||||
|
|
||||||
|
assert coord_ieee in node_by_id
|
||||||
|
assert router_ieee in node_by_id
|
||||||
|
assert end_ieee in node_by_id
|
||||||
|
|
||||||
|
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
|
||||||
|
assert node_by_id[router_ieee]["type"] == "zigbee_router"
|
||||||
|
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
|
||||||
|
|
||||||
|
# Parent hierarchy
|
||||||
|
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
|
||||||
|
assert node_by_id[end_ieee]["parent_id"] == router_ieee
|
||||||
|
assert len(edges) == 2
|
||||||
|
|
||||||
|
def test_no_duplicate_nodes(self) -> None:
|
||||||
|
ieee = "0x0000000000000001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
|
||||||
|
)
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
|
||||||
|
def test_edges_built_correctly(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
router = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||||
|
links=[_make_link(coord, router)],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert len(edges) == 1
|
||||||
|
assert edges[0]["source"] == coord
|
||||||
|
assert edges[0]["target"] == router
|
||||||
|
|
||||||
|
def test_friendly_name_used_as_label(self) -> None:
|
||||||
|
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert nodes[0]["label"] == "Living Room Sensor"
|
||||||
|
|
||||||
|
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0003"
|
||||||
|
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
end_node = next(n for n in nodes if n["id"] == end)
|
||||||
|
assert end_node["parent_id"] == coord
|
||||||
|
|
||||||
|
def test_missing_ieee_skipped(self) -> None:
|
||||||
|
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_lqi_propagated_from_link_to_target_node(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||||
|
links=[_make_link(coord, end, lqi=180)],
|
||||||
|
)
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
end_node = next(n for n in nodes if n["id"] == end)
|
||||||
|
assert end_node["lqi"] == 180
|
||||||
|
|
||||||
|
def test_definition_model_and_vendor_extracted(self) -> None:
|
||||||
|
payload = _wrap([
|
||||||
|
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
|
||||||
|
])
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert nodes[0]["model"] == "WSDCGQ11LM"
|
||||||
|
assert nodes[0]["vendor"] == "Aqara"
|
||||||
|
|
||||||
|
def test_legacy_shape_without_value_wrapper(self) -> None:
|
||||||
|
"""Some Z2M variants put nodes/links directly under data."""
|
||||||
|
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
|
||||||
|
nodes, _ = parse_networkmap(payload)
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["type"] == "zigbee_coordinator"
|
||||||
|
|
||||||
|
def test_routes_bool_is_ignored(self) -> None:
|
||||||
|
"""`routes: false` echo from the request must not crash the parser."""
|
||||||
|
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
|
||||||
|
nodes, edges = parse_networkmap(payload)
|
||||||
|
assert nodes == []
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_malformed_nodes_not_list_raises(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="not a list"):
|
||||||
|
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
|
||||||
|
|
||||||
|
def test_link_to_unknown_node_dropped(self) -> None:
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node("0x01", "Coordinator")],
|
||||||
|
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert edges == []
|
||||||
|
|
||||||
|
def test_bidirectional_links_yield_single_edge(self) -> None:
|
||||||
|
"""Z2M links are bidirectional — every pair appears twice. The output
|
||||||
|
must collapse to a single parent→child edge (no back-link, no dup)."""
|
||||||
|
coord = "0x0000"
|
||||||
|
router = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
||||||
|
links=[
|
||||||
|
_make_link(coord, router),
|
||||||
|
_make_link(router, coord), # reverse direction
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
assert edges == [{"source": coord, "target": router}]
|
||||||
|
|
||||||
|
def test_router_mesh_siblings_dropped(self) -> None:
|
||||||
|
"""Router↔router mesh paths in `links` must NOT produce sibling edges
|
||||||
|
in the final tree. Each router gets exactly one edge from coordinator."""
|
||||||
|
coord = "0x0000"
|
||||||
|
r1 = "0x0001"
|
||||||
|
r2 = "0x0002"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[
|
||||||
|
_make_node(coord, "Coordinator"),
|
||||||
|
_make_node(r1, "Router"),
|
||||||
|
_make_node(r2, "Router"),
|
||||||
|
],
|
||||||
|
links=[
|
||||||
|
_make_link(coord, r1),
|
||||||
|
_make_link(coord, r2),
|
||||||
|
_make_link(r1, r2), # mesh sibling — must be dropped
|
||||||
|
_make_link(r2, r1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
pairs = {(e["source"], e["target"]) for e in edges}
|
||||||
|
assert pairs == {(coord, r1), (coord, r2)}
|
||||||
|
|
||||||
|
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||||
|
coord = "0x0000"
|
||||||
|
end = "0x0001"
|
||||||
|
payload = _wrap(
|
||||||
|
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
||||||
|
links=[_make_link(end, coord)], # back-edge from end to coord
|
||||||
|
)
|
||||||
|
_, edges = parse_networkmap(payload)
|
||||||
|
# No edge should target the coordinator
|
||||||
|
assert all(e["target"] != coord for e in edges)
|
||||||
|
assert edges == [{"source": coord, "target": end}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _find_parent_router
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFindParentRouter:
|
||||||
|
def test_finds_router_as_source(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "r1", "target": "e1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_finds_router_as_target(self) -> None:
|
||||||
|
router_ids = {"r1"}
|
||||||
|
edges = [{"source": "e1", "target": "r1"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
||||||
|
|
||||||
|
def test_returns_none_when_no_router(self) -> None:
|
||||||
|
router_ids: set[str] = set()
|
||||||
|
edges = [{"source": "e1", "target": "e2"}]
|
||||||
|
assert _find_parent_router("e1", router_ids, edges) is None
|
||||||
|
|
||||||
|
def test_returns_none_empty_edges(self) -> None:
|
||||||
|
assert _find_parent_router("e1", {"r1"}, []) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_networkmap (integration-style with mocked aiomqtt)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SAMPLE_RESPONSE_PAYLOAD = {
|
||||||
|
"data": {
|
||||||
|
"type": "raw",
|
||||||
|
"routes": False,
|
||||||
|
"value": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"ieeeAddr": "0x00000000",
|
||||||
|
"type": "Coordinator",
|
||||||
|
"friendlyName": "Coordinator",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ieeeAddr": "0x00000001",
|
||||||
|
"type": "Router",
|
||||||
|
"friendlyName": "router_1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"source": {"ieeeAddr": "0x00000000"},
|
||||||
|
"target": {"ieeeAddr": "0x00000001"},
|
||||||
|
"lqi": 230,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"status": "ok",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_success() -> None:
|
||||||
|
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
|
||||||
|
|
||||||
|
class _FakeMessage:
|
||||||
|
topic = "zigbee2mqtt/bridge/response/networkmap"
|
||||||
|
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
|
||||||
|
_yielded = False
|
||||||
|
|
||||||
|
def __aiter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __anext__(self):
|
||||||
|
if self._yielded:
|
||||||
|
raise StopAsyncIteration
|
||||||
|
self._yielded = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def subscribe(self, _topic: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def publish(self, _topic: str, _payload: str) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def messages(self):
|
||||||
|
return _FakeMessage()
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
nodes, edges = await fetch_networkmap(
|
||||||
|
mqtt_host="localhost",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
|
||||||
|
assert any(n["type"] == "zigbee_router" for n in nodes)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_connection_error() -> None:
|
||||||
|
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Connection refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await fetch_networkmap(
|
||||||
|
mqtt_host="bad-host",
|
||||||
|
mqtt_port=1883,
|
||||||
|
base_topic="zigbee2mqtt",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_success() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
result = await _test_mqtt_connection("localhost", 1883)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_failure() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("refused")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
await _test_mqtt_connection("bad-host", 1883)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TLS context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
import ssl # noqa: E402
|
||||||
|
|
||||||
|
from app.services.zigbee_service import _build_tls_context # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tls_context_secure_verifies_cert() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=False)
|
||||||
|
assert ctx.check_hostname is True
|
||||||
|
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_tls_context_insecure_disables_verification() -> None:
|
||||||
|
ctx = _build_tls_context(insecure=True)
|
||||||
|
assert ctx.check_hostname is False
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_passes_tls_context() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 8883, tls=True)
|
||||||
|
kwargs = mock_aiomqtt.Client.call_args.kwargs
|
||||||
|
assert kwargs["tls_context"] is not None
|
||||||
|
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 1883, tls=False)
|
||||||
|
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
|
||||||
|
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
||||||
|
assert ctx.verify_mode == ssl.CERT_NONE
|
||||||
|
assert ctx.check_hostname is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sanitize MQTT errors
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(
|
||||||
|
Exception("Not authorized: bad username or password for user=admin pwd=secret")
|
||||||
|
)
|
||||||
|
assert msg == "Authentication failed"
|
||||||
|
assert "admin" not in msg
|
||||||
|
assert "secret" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_refused() -> None:
|
||||||
|
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_dns_failure_strips_host() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(
|
||||||
|
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
|
||||||
|
)
|
||||||
|
assert msg == "Broker hostname could not be resolved"
|
||||||
|
assert "broker.internal.lan" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_tls_error() -> None:
|
||||||
|
assert _sanitize_mqtt_error(
|
||||||
|
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||||
|
) == "TLS handshake failed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_unknown_falls_back_to_generic() -> None:
|
||||||
|
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
|
||||||
|
assert msg == "MQTT connection failed"
|
||||||
|
assert "hunter2" not in msg
|
||||||
|
assert "admin" not in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
|
||||||
|
class _FakeClient:
|
||||||
|
async def __aenter__(self):
|
||||||
|
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
|
||||||
|
|
||||||
|
async def __aexit__(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
||||||
|
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||||
|
mock_aiomqtt.MqttError = Exception
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError) as ei:
|
||||||
|
await fetch_networkmap(
|
||||||
|
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
||||||
|
)
|
||||||
|
msg = str(ei.value)
|
||||||
|
assert "hunter2" not in msg
|
||||||
|
assert "admin" not in msg
|
||||||
|
assert msg == "Authentication failed"
|
||||||
@@ -24,6 +24,20 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- homelable
|
- 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:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Zigbee2MQTT Network Map Importer
|
||||||
|
|
||||||
|
This feature lets you connect Homelable to your MQTT broker, fetch the Zigbee2MQTT network topology, and drop all Zigbee devices onto the canvas as typed nodes with proper hierarchy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
- **Automatic device discovery** — Requests the Z2M networkmap via the MQTT bridge API and parses the full device list
|
||||||
|
- **Typed nodes** — Devices are mapped to three homelable node types:
|
||||||
|
- `zigbee_coordinator` — The Zigbee coordinator (hub)
|
||||||
|
- `zigbee_router` — Mains-powered router devices
|
||||||
|
- `zigbee_enddevice` — Battery-powered end devices (sensors, bulbs, etc.)
|
||||||
|
- **Hierarchy** — `parent_id` is set automatically: coordinator → routers → end devices
|
||||||
|
- **LQI display** — Link Quality Indicator is stored as a node property
|
||||||
|
- **IoT edges** — Links between devices are added as `IoT / Zigbee` edge type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A running **MQTT broker** (e.g. Mosquitto) accessible from your Homelable host
|
||||||
|
2. **Zigbee2MQTT** connected to the broker and running
|
||||||
|
3. Z2M must respond to networkmap requests on:
|
||||||
|
- **Request topic:** `<base_topic>/bridge/request/networkmap`
|
||||||
|
- **Response topic:** `<base_topic>/bridge/response/networkmap`
|
||||||
|
- The default base topic is `zigbee2mqtt`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step Usage
|
||||||
|
|
||||||
|
### 1. Open the Zigbee Import dialog
|
||||||
|
|
||||||
|
Click **Zigbee Import** in the left sidebar (below "Scan Network").
|
||||||
|
|
||||||
|
### 2. Configure the MQTT connection
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Broker Host | — | IP or hostname of your MQTT broker |
|
||||||
|
| Port | 1883 | MQTT broker port |
|
||||||
|
| Base Topic | `zigbee2mqtt` | Zigbee2MQTT base topic |
|
||||||
|
| Username | _(optional)_ | MQTT username if authentication is enabled |
|
||||||
|
| Password | _(optional)_ | MQTT password |
|
||||||
|
|
||||||
|
### 3. Test the connection (optional)
|
||||||
|
|
||||||
|
Click **Test Connection** to verify broker reachability before fetching devices.
|
||||||
|
A green indicator confirms success; red shows the error message from the broker.
|
||||||
|
|
||||||
|
### 4. Fetch devices
|
||||||
|
|
||||||
|
Click **Fetch Devices**. Homelable will:
|
||||||
|
1. Connect to the broker
|
||||||
|
2. Subscribe to the response topic
|
||||||
|
3. Publish `{"type": "raw", "routes": false}` to the request topic
|
||||||
|
4. Wait up to 60 seconds for the network map response (large meshes can take 30 s+)
|
||||||
|
5. Parse and group devices by type
|
||||||
|
|
||||||
|
### 5. Select and add to canvas
|
||||||
|
|
||||||
|
Devices are grouped by type (Coordinator / Router / End Device).
|
||||||
|
Use the checkboxes to select which devices to add, then click **Add N to Canvas**.
|
||||||
|
|
||||||
|
> **Tip:** All devices are selected by default. Uncheck any you don't want.
|
||||||
|
|
||||||
|
### 6. Arrange on the canvas
|
||||||
|
|
||||||
|
Devices are placed in a grid at the top-right of the canvas.
|
||||||
|
Use **Auto Layout** (toolbar) to re-arrange the full canvas, or drag nodes manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MQTT Configuration Tips
|
||||||
|
|
||||||
|
### Mosquitto without authentication
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mosquitto with password file
|
||||||
|
|
||||||
|
```
|
||||||
|
listener 1883
|
||||||
|
password_file /etc/mosquitto/passwd
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a user:
|
||||||
|
```bash
|
||||||
|
mosquitto_passwd -c /etc/mosquitto/passwd <username>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zigbee2MQTT `configuration.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mqtt:
|
||||||
|
base_topic: zigbee2mqtt
|
||||||
|
server: mqtt://localhost:1883
|
||||||
|
# user: mqtt_user
|
||||||
|
# password: mqtt_password
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Z2M Versions
|
||||||
|
|
||||||
|
The networkmap bridge API is available in **Zigbee2MQTT 1.x and 2.x**.
|
||||||
|
Tested against Z2M 1.35+ and 2.x.
|
||||||
|
|
||||||
|
The importer uses the `raw` topology format (`routes: false`) which is the most widely supported mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Connection refused" | Broker unreachable | Check host/port, firewall rules |
|
||||||
|
| "Timed out waiting for networkmap" | Z2M not running or wrong base_topic | Verify Z2M is connected, check base_topic setting |
|
||||||
|
| 0 devices returned | Z2M has no devices paired | Pair at least one device first |
|
||||||
|
| "Malformed networkmap response" | Z2M returned unexpected format | Check Z2M version; open an issue |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
_(Screenshots will be added in a future release)_
|
||||||
Generated
+29
-29
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.10.2",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.10.2",
|
"version": "2.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
@@ -1646,9 +1646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.12",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -4186,12 +4186,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.11",
|
"follow-redirects": "^1.16.0",
|
||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^2.1.0"
|
"proxy-from-env": "^2.1.0"
|
||||||
}
|
}
|
||||||
@@ -5602,12 +5602,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "8.3.2",
|
"version": "8.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.1.tgz",
|
||||||
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
|
"integrity": "sha512-5O6KYmyJEpuPJV5hNTXKbAHWRqrzyu+OI3vUnSd2kXFubIVpG7ezpgxQy76Zo5GQZtrQBg86hF+CM/NX+cioiQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ip-address": "10.1.0"
|
"ip-address": "^10.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 16"
|
"node": ">= 16"
|
||||||
@@ -5693,9 +5693,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -5857,9 +5857,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.15.11",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6196,9 +6196,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.11",
|
"version": "4.12.18",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz",
|
||||||
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
"integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -6354,9 +6354,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -7878,9 +7878,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.8",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.2",
|
"version": "2.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.15.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Regenerate frontend/src/data/dashboardIcons.json from the upstream
|
||||||
|
// homarr-labs/dashboard-icons repo. Run manually to refresh the manifest.
|
||||||
|
//
|
||||||
|
// node scripts/fetch-dashboard-icons.mjs
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const TREE_URL = 'https://raw.githubusercontent.com/homarr-labs/dashboard-icons/main/tree.json'
|
||||||
|
const OUT = resolve(dirname(fileURLToPath(import.meta.url)), '../src/data/dashboardIcons.json')
|
||||||
|
|
||||||
|
const res = await fetch(TREE_URL)
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`fetch failed: ${res.status} ${res.statusText}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
const tree = await res.json()
|
||||||
|
const slugs = (tree.svg ?? [])
|
||||||
|
.filter((f) => f.endsWith('.svg'))
|
||||||
|
.map((f) => f.slice(0, -4))
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true })
|
||||||
|
writeFileSync(OUT, JSON.stringify(slugs))
|
||||||
|
console.log(`wrote ${slugs.length} slugs → ${OUT}`)
|
||||||
+321
-69
@@ -4,6 +4,7 @@ import { type Node } from '@xyflow/react'
|
|||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
@@ -19,74 +20,136 @@ import { LoginPage } from '@/components/LoginPage'
|
|||||||
import { NodeModal } from '@/components/modals/NodeModal'
|
import { NodeModal } from '@/components/modals/NodeModal'
|
||||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||||
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { canvasApi } from '@/api/client'
|
import { canvasApi, designsApi } from '@/api/client'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||||
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||||
const CONTAINER_MODE_TYPES = new Set<NodeData['type']>(['proxmox', 'vm', 'lxc', 'docker_host'])
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, 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, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
const [sidebarForceView, setSidebarForceView] = useState<'history' | undefined>(undefined)
|
||||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
|
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||||
|
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||||
|
const openPendingModal = useCallback((deviceId?: string, status: 'pending' | 'hidden' = 'pending') => {
|
||||||
|
setPendingHighlightId(undefined)
|
||||||
|
setPendingModalStatus(status)
|
||||||
|
setPendingModalOpen(true)
|
||||||
|
if (deviceId) setTimeout(() => setPendingHighlightId(deviceId), 0)
|
||||||
|
}, [])
|
||||||
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
const [shortcutsOpen, setShortcutsOpen] = useState(false)
|
||||||
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
const [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||||
|
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
const handleSave = useCallback(async () => {
|
// 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 {
|
try {
|
||||||
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
|
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
const edgesToSave = edges.map(serializeEdge)
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
toast.success('Canvas saved')
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}, [nodes, edges, markSaved, activeTheme])
|
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
|
||||||
|
|
||||||
// Keep a ref so the keydown handler always calls the latest version
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||||
|
|
||||||
|
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||||
|
try {
|
||||||
|
const res = await canvasApi.load(designId)
|
||||||
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
|
if (apiNodes.length > 0) {
|
||||||
|
const proxmoxContainerMap = new Map<string, boolean>(
|
||||||
|
(apiNodes as ApiNode[])
|
||||||
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
|
.map((n) => [n.id, true])
|
||||||
|
)
|
||||||
|
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||||
|
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||||
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
|
if (savedTheme) setTheme(savedTheme)
|
||||||
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
|
loadCanvas(rfNodes, rfEdges)
|
||||||
|
} else {
|
||||||
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
}
|
||||||
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
|
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)
|
// Load canvas on auth (or immediately in standalone mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const { nodes: savedNodes, edges: savedEdges, theme_id } = JSON.parse(saved)
|
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
|
||||||
if (theme_id) setTheme(theme_id)
|
if (theme_id) setTheme(theme_id)
|
||||||
|
if (custom_style) setCustomStyle(custom_style)
|
||||||
loadCanvas(savedNodes, savedEdges)
|
loadCanvas(savedNodes, savedEdges)
|
||||||
} else {
|
} else {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
@@ -97,27 +160,53 @@ export default function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) return
|
if (!isAuthenticated) return
|
||||||
canvasApi.load()
|
loadDesignsAndCanvas()
|
||||||
.then((res) => {
|
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
|
||||||
if (apiNodes.length > 0) {
|
// Reload canvas when active design changes (after initial load)
|
||||||
// Build a map of container mode nodes to know if children should be nested
|
const initialLoadDone = useRef(false)
|
||||||
const proxmoxContainerMap = new Map<string, boolean>(
|
const prevDesignRef = useRef<string | null>(null)
|
||||||
(apiNodes as ApiNode[])
|
// Set while we programmatically revert activeDesignId after a failed save, so
|
||||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
// the re-entrant effect run skips save/load and just re-syncs the refs.
|
||||||
.map((n) => [n.id, true])
|
const revertingRef = useRef(false)
|
||||||
)
|
useEffect(() => {
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
if (revertingRef.current) {
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
revertingRef.current = false
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
prevDesignRef.current = activeDesignId
|
||||||
if (savedTheme) setTheme(savedTheme)
|
return
|
||||||
loadCanvas(rfNodes, rfEdges)
|
}
|
||||||
} else {
|
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
||||||
loadCanvas(demoNodes, demoEdges)
|
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.
|
||||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false
|
||||||
}, [isAuthenticated, loadCanvas, setTheme])
|
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
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
@@ -227,6 +316,67 @@ export default function App() {
|
|||||||
setEditingGroupRectId(null)
|
setEditingGroupRectId(null)
|
||||||
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
}, [editingGroupRectId, nodes, updateNode, setNodeZIndex, setEditingGroupRectId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleAddText = useCallback((data: TextFormData) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const id = generateUUID()
|
||||||
|
const newNode: Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
// Text lives in `label` because the API serializer only persists top-level
|
||||||
|
// node fields; text_content is not in the schema and was lost on reload.
|
||||||
|
// TextNode and the edit modal both already fall back to label.
|
||||||
|
type: 'text',
|
||||||
|
position: { x: 250, y: 250 },
|
||||||
|
data: {
|
||||||
|
label: data.text,
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
custom_colors: {
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
}, [addNode, snapshotHistory])
|
||||||
|
|
||||||
|
const handleUpdateText = useCallback((data: TextFormData) => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
const existing = nodes.find((n) => n.id === editingTextId)
|
||||||
|
updateNode(editingTextId, {
|
||||||
|
label: data.text,
|
||||||
|
// Clear stale text_content if present from older builds — label is the
|
||||||
|
// source of truth now.
|
||||||
|
text_content: undefined,
|
||||||
|
custom_colors: {
|
||||||
|
...existing?.data.custom_colors,
|
||||||
|
border: data.border_color,
|
||||||
|
border_style: data.border_style,
|
||||||
|
border_width: data.border_width,
|
||||||
|
background: data.background_color,
|
||||||
|
text_color: data.text_color,
|
||||||
|
text_size: data.text_size,
|
||||||
|
font: data.font,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, nodes, updateNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
|
const handleDeleteText = useCallback(() => {
|
||||||
|
if (!editingTextId) return
|
||||||
|
snapshotHistory()
|
||||||
|
deleteNode(editingTextId)
|
||||||
|
setEditingTextId(null)
|
||||||
|
}, [editingTextId, deleteNode, setEditingTextId, snapshotHistory])
|
||||||
|
|
||||||
const handleDeleteGroupRect = useCallback(() => {
|
const handleDeleteGroupRect = useCallback(() => {
|
||||||
if (!editingGroupRectId) return
|
if (!editingGroupRectId) return
|
||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
@@ -313,6 +463,54 @@ export default function App() {
|
|||||||
setExportModalOpen(true)
|
setExportModalOpen(true)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
// Place nodes in a grid starting at x=500, y=100
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 170
|
||||||
|
const SPACING_Y = 100
|
||||||
|
zigbeeNodes.forEach((zn, i) => {
|
||||||
|
const id = zn.id
|
||||||
|
const col = i % COLS
|
||||||
|
const row = Math.floor(i / COLS)
|
||||||
|
const position = { x: 500 + col * SPACING_X, y: 100 + row * SPACING_Y }
|
||||||
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
|
id,
|
||||||
|
type: zn.type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: zn.friendly_name,
|
||||||
|
type: zn.type as NodeData['type'],
|
||||||
|
status: 'unknown' as const,
|
||||||
|
services: [],
|
||||||
|
...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
|
||||||
|
...(zn.model ? { os: zn.model } : {}),
|
||||||
|
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Add IoT edges between Zigbee devices: parent bottom -> child top
|
||||||
|
zigbeeEdges.forEach((ze) => {
|
||||||
|
onConnect({
|
||||||
|
source: ze.source,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: ze.target,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
// Auto-select only the freshly imported nodes so the user can drag the
|
||||||
|
// whole subtree as a group.
|
||||||
|
const importedIds = new Set(zigbeeNodes.map((zn) => zn.id))
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||||
|
selectedNodeIds: Array.from(importedIds),
|
||||||
|
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||||
|
}))
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -325,16 +523,14 @@ export default function App() {
|
|||||||
if (edgeData.type === 'virtual') {
|
if (edgeData.type === 'virtual') {
|
||||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||||
const srcType = src?.data.type as NodeData['type']
|
if (src && tgt) {
|
||||||
const tgtType = tgt?.data.type as NodeData['type']
|
const assignment = resolveVirtualEdgeParent(
|
||||||
if ((srcType === 'lxc' || srcType === 'vm') && CONTAINER_MODE_TYPES.has(tgtType)) {
|
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||||
} else if (CONTAINER_MODE_TYPES.has(srcType) && (tgtType === 'lxc' || tgtType === 'vm')) {
|
)
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
if (assignment) {
|
||||||
} else if (srcType === 'docker_container' && tgtType === 'docker_host') {
|
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
}
|
||||||
} else if (tgtType === 'docker_container' && srcType === 'docker_host') {
|
|
||||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setPendingConnection(null)
|
setPendingConnection(null)
|
||||||
@@ -345,6 +541,11 @@ export default function App() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
const handleNodeDoubleClick = useCallback((node: Node<NodeData>) => {
|
||||||
|
// 'group' uses inline rename (pencil button in header). Opening the
|
||||||
|
// generic NodeModal would clobber the group's height (via the
|
||||||
|
// properties-clears-height rule in updateNode) and lose its children.
|
||||||
|
// 'groupRect' has its own onDoubleClick that already routes to GroupRectModal.
|
||||||
|
if (node.data.type === 'group' || node.data.type === 'groupRect') return
|
||||||
handleEditNode(node.id)
|
handleEditNode(node.id)
|
||||||
}, [handleEditNode])
|
}, [handleEditNode])
|
||||||
|
|
||||||
@@ -381,11 +582,12 @@ export default function App() {
|
|||||||
<Sidebar
|
<Sidebar
|
||||||
onAddNode={() => setAddNodeOpen(true)}
|
onAddNode={() => setAddNodeOpen(true)}
|
||||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||||
|
onAddText={() => setAddTextOpen(true)}
|
||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onNodeApproved={setEditNodeId}
|
|
||||||
forceView={sidebarForceView}
|
forceView={sidebarForceView}
|
||||||
highlightPendingId={highlightPendingId}
|
onOpenPending={openPendingModal}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -407,14 +609,7 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
@@ -428,9 +623,7 @@ export default function App() {
|
|||||||
onClose={() => setAddNodeOpen(false)}
|
onClose={() => setAddNodeOpen(false)}
|
||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentContainerNodes={nodes
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type }))}
|
||||||
.filter((n) => CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
|
||||||
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -441,9 +634,25 @@ export default function App() {
|
|||||||
onSubmit={handleUpdateNode}
|
onSubmit={handleUpdateNode}
|
||||||
initial={editNode?.data}
|
initial={editNode?.data}
|
||||||
title="Edit Node"
|
title="Edit Node"
|
||||||
parentContainerNodes={nodes
|
parentCandidates={(() => {
|
||||||
.filter((n) => n.id !== editNodeId && CONTAINER_MODE_TYPES.has(n.data.type) && n.data.container_mode)
|
const descendants = new Set<string>()
|
||||||
.map((n) => ({ id: n.id, label: n.data.label, nodeType: n.data.type }))}
|
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
|
<EdgeModal
|
||||||
@@ -481,6 +690,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZigbeeImportModal
|
||||||
|
open={zigbeeImportOpen}
|
||||||
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
setSidebarForceView(undefined)
|
||||||
|
setTimeout(() => setSidebarForceView('history'), 0)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -516,6 +737,37 @@ export default function App() {
|
|||||||
title="Edit Zone"
|
title="Edit Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
open={addTextOpen}
|
||||||
|
onClose={() => setAddTextOpen(false)}
|
||||||
|
onSubmit={handleAddText}
|
||||||
|
title="Add Text"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextModal
|
||||||
|
key={editingTextId ?? 'text-edit'}
|
||||||
|
open={!!editingTextId}
|
||||||
|
onClose={() => setEditingTextId(null)}
|
||||||
|
onSubmit={handleUpdateText}
|
||||||
|
onDelete={handleDeleteText}
|
||||||
|
initial={(() => {
|
||||||
|
const n = editingTextId ? nodes.find((nd) => nd.id === editingTextId) : null
|
||||||
|
if (!n) return undefined
|
||||||
|
const rc = n.data.custom_colors ?? {}
|
||||||
|
return {
|
||||||
|
text: n.data.text_content ?? n.data.label ?? '',
|
||||||
|
font: rc.font ?? 'inter',
|
||||||
|
text_color: rc.text_color ?? '#e6edf3',
|
||||||
|
text_size: rc.text_size ?? 14,
|
||||||
|
border_color: rc.border ?? '#30363d',
|
||||||
|
border_style: (rc.border_style ?? 'none') as TextFormData['border_style'],
|
||||||
|
border_width: rc.border_width ?? 1,
|
||||||
|
background_color: rc.background ?? '#00000000',
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
title="Edit Text"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount on open so useState captures current theme as original */}
|
{/* key forces re-mount on open so useState captures current theme as original */}
|
||||||
<ThemeModal
|
<ThemeModal
|
||||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||||
@@ -526,17 +778,17 @@ export default function App() {
|
|||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
onOpenPending={(deviceId) => {
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setHighlightPendingId(undefined)
|
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||||
|
|
||||||
|
<PendingDevicesModal
|
||||||
|
open={pendingModalOpen}
|
||||||
|
onClose={() => setPendingModalOpen(false)}
|
||||||
|
highlightId={pendingHighlightId}
|
||||||
|
initialStatus={pendingModalStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
open={exportModalOpen}
|
open={exportModalOpen}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
type Interceptor<T> = {
|
||||||
|
fulfilled?: (v: T) => T | Promise<T>
|
||||||
|
rejected?: (e: unknown) => unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MockInstance {
|
||||||
|
defaults: { baseURL?: string }
|
||||||
|
interceptors: {
|
||||||
|
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||||
|
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
|
||||||
|
}
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
patch: ReturnType<typeof vi.fn>
|
||||||
|
delete: ReturnType<typeof vi.fn>
|
||||||
|
__req: Interceptor<{ headers: Record<string, string> }>
|
||||||
|
__res: Interceptor<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({ instances: [] as unknown[] }))
|
||||||
|
const instances = hoisted.instances as MockInstance[]
|
||||||
|
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
create: (cfg: { baseURL?: string }) => {
|
||||||
|
const inst: MockInstance = {
|
||||||
|
defaults: { baseURL: cfg?.baseURL },
|
||||||
|
interceptors: {
|
||||||
|
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
|
||||||
|
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
|
||||||
|
},
|
||||||
|
get: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
post: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: vi.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
__req: {},
|
||||||
|
__res: {},
|
||||||
|
}
|
||||||
|
hoisted.instances.push(inst)
|
||||||
|
return inst
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import * as clientModule from '../client'
|
||||||
|
|
||||||
|
describe('api/client', () => {
|
||||||
|
const mod = clientModule
|
||||||
|
const [api, publicApi] = instances
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthStore.setState({ token: null, isAuthenticated: false })
|
||||||
|
api.get.mockClear()
|
||||||
|
api.post.mockClear()
|
||||||
|
api.patch.mockClear()
|
||||||
|
api.delete.mockClear()
|
||||||
|
publicApi.get.mockClear()
|
||||||
|
publicApi.post.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates two axios instances with /api/v1 baseURL', () => {
|
||||||
|
expect(instances).toHaveLength(2)
|
||||||
|
expect(api.defaults.baseURL).toBe('/api/v1')
|
||||||
|
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exports `api` matching the first created instance', () => {
|
||||||
|
expect(mod.api).toBe(api)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('request interceptor adds Authorization header when token present', () => {
|
||||||
|
useAuthStore.setState({ token: 'tok-123', isAuthenticated: true })
|
||||||
|
const cfg = { headers: {} as Record<string, string> }
|
||||||
|
const out = api.__req.fulfilled!(cfg)
|
||||||
|
expect((out as typeof cfg).headers.Authorization).toBe('Bearer tok-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('request interceptor leaves headers untouched when no token', () => {
|
||||||
|
const cfg = { headers: {} as Record<string, string> }
|
||||||
|
const out = api.__req.fulfilled!(cfg)
|
||||||
|
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor passes through 2xx responses', () => {
|
||||||
|
const r = { status: 200, data: { ok: true } }
|
||||||
|
expect(api.__res.fulfilled!(r)).toBe(r)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor calls logout on 401', async () => {
|
||||||
|
const logout = vi.spyOn(useAuthStore.getState(), 'logout')
|
||||||
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||||
|
const err = { response: { status: 401 } }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor does not call logout on non-401', async () => {
|
||||||
|
const logout = vi.fn()
|
||||||
|
useAuthStore.setState({ token: 't', isAuthenticated: true, logout })
|
||||||
|
const err = { response: { status: 500 } }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('response interceptor handles error with no response object', async () => {
|
||||||
|
const logout = vi.fn()
|
||||||
|
useAuthStore.setState({ logout })
|
||||||
|
const err = { message: 'network down' }
|
||||||
|
await expect(api.__res.rejected!(err)).rejects.toBe(err)
|
||||||
|
expect(logout).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('publicApi has no request/response interceptors registered', () => {
|
||||||
|
expect(publicApi.__req.fulfilled).toBeUndefined()
|
||||||
|
expect(publicApi.__res.fulfilled).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authApi.login posts to /auth/login', () => {
|
||||||
|
mod.authApi.login('u', 'p')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canvasApi.load GETs /canvas', () => {
|
||||||
|
mod.canvasApi.load()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('canvasApi.save POSTs to /canvas/save with payload', () => {
|
||||||
|
const payload = { nodes: [], edges: [], viewport: {} }
|
||||||
|
mod.canvasApi.save(payload)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/canvas/save', payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('nodesApi CRUD calls correct endpoints', () => {
|
||||||
|
mod.nodesApi.create({ a: 1 })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/nodes', { a: 1 })
|
||||||
|
mod.nodesApi.update('n1', { b: 2 })
|
||||||
|
expect(api.patch).toHaveBeenCalledWith('/nodes/n1', { b: 2 })
|
||||||
|
mod.nodesApi.delete('n1')
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/nodes/n1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('edgesApi CRUD calls correct endpoints', () => {
|
||||||
|
mod.edgesApi.create({ s: 'a', t: 'b' })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/edges', { s: 'a', t: 'b' })
|
||||||
|
mod.edgesApi.delete('e1')
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/edges/e1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liveviewApi.load uses publicApi with key param', () => {
|
||||||
|
mod.liveviewApi.load('k-1')
|
||||||
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1' } })
|
||||||
|
expect(api.get).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scanApi endpoints route correctly', () => {
|
||||||
|
mod.scanApi.trigger()
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||||
|
mod.scanApi.pending()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/pending')
|
||||||
|
mod.scanApi.hidden()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/hidden')
|
||||||
|
mod.scanApi.runs()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/runs')
|
||||||
|
mod.scanApi.clearPending()
|
||||||
|
expect(api.delete).toHaveBeenCalledWith('/scan/pending')
|
||||||
|
mod.scanApi.approve('d1', { foo: 'bar' })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/approve', { foo: 'bar' })
|
||||||
|
mod.scanApi.hide('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/hide')
|
||||||
|
mod.scanApi.ignore('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/ignore')
|
||||||
|
mod.scanApi.bulkApprove(['a', 'b'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a', 'b'] })
|
||||||
|
mod.scanApi.bulkHide(['a'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-hide', { device_ids: ['a'] })
|
||||||
|
mod.scanApi.restore('d1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/d1/restore')
|
||||||
|
mod.scanApi.bulkRestore(['a'])
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-restore', { device_ids: ['a'] })
|
||||||
|
mod.scanApi.stop('run-1')
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/run-1/stop')
|
||||||
|
mod.scanApi.getConfig()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/scan/config')
|
||||||
|
mod.scanApi.saveConfig({ ranges: ['1.0/24'] })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/config', { ranges: ['1.0/24'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('settingsApi get/save', () => {
|
||||||
|
mod.settingsApi.get()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/settings')
|
||||||
|
mod.settingsApi.save({ interval_seconds: 30 })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
const cfg = { mqtt_host: 'h', mqtt_port: 1883 }
|
||||||
|
mod.zigbeeApi.testConnection(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/test-connection', cfg)
|
||||||
|
mod.zigbeeApi.importNetwork(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import', cfg)
|
||||||
|
mod.zigbeeApi.importToPending(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/zigbee/import-pending', cfg)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -28,11 +28,16 @@ export const authApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const canvasApi = {
|
export const canvasApi = {
|
||||||
load: () => api.get('/canvas'),
|
load: (design_id?: string) => {
|
||||||
|
const params = design_id ? { design_id } : {}
|
||||||
|
return api.get('/canvas', { params })
|
||||||
|
},
|
||||||
save: (payload: {
|
save: (payload: {
|
||||||
nodes: object[]
|
nodes: object[]
|
||||||
edges: object[]
|
edges: object[]
|
||||||
viewport: object
|
viewport: object
|
||||||
|
custom_style?: object | null
|
||||||
|
design_id?: string | null
|
||||||
}) => api.post('/canvas/save', payload),
|
}) => api.post('/canvas/save', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,11 +62,27 @@ export const scanApi = {
|
|||||||
hidden: () => api.get('/scan/hidden'),
|
hidden: () => api.get('/scan/hidden'),
|
||||||
runs: () => api.get('/scan/runs'),
|
runs: () => api.get('/scan/runs'),
|
||||||
clearPending: () => api.delete('/scan/pending'),
|
clearPending: () => api.delete('/scan/pending'),
|
||||||
approve: (id: string, nodeData: object) => api.post(`/scan/pending/${id}/approve`, nodeData),
|
approve: (id: string, nodeData: object) =>
|
||||||
|
api.post<{
|
||||||
|
approved: boolean
|
||||||
|
node_id: string
|
||||||
|
edges_created: number
|
||||||
|
edges: { id: string; source: string; target: string }[]
|
||||||
|
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
bulkApprove: (ids: string[]) => api.post<{ approved: number; node_ids: string[]; device_ids: string[]; skipped: number }>('/scan/pending/bulk-approve', { device_ids: ids }),
|
bulkApprove: (ids: string[]) =>
|
||||||
|
api.post<{
|
||||||
|
approved: number
|
||||||
|
node_ids: string[]
|
||||||
|
device_ids: string[]
|
||||||
|
edges_created: number
|
||||||
|
edges: { id: string; source: string; target: string }[]
|
||||||
|
skipped: number
|
||||||
|
}>('/scan/pending/bulk-approve', { device_ids: ids }),
|
||||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
|
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||||
|
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
||||||
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
stop: (runId: string) => api.post(`/scan/${runId}/stop`),
|
||||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||||
@@ -71,3 +92,59 @@ export const settingsApi = {
|
|||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const designsApi = {
|
||||||
|
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||||
|
create: (data: { name: string; 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
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/zigbee/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
base_topic?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
nodes: import('@/components/zigbee/types').ZigbeeNode[]
|
||||||
|
edges: import('@/components/zigbee/types').ZigbeeEdge[]
|
||||||
|
device_count: number
|
||||||
|
}>('/zigbee/import', data),
|
||||||
|
|
||||||
|
importToPending: (data: {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
base_topic?: string
|
||||||
|
mqtt_tls?: boolean
|
||||||
|
mqtt_tls_insecure?: boolean
|
||||||
|
}) =>
|
||||||
|
api.post<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/zigbee/import-pending', data),
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
* Clicking a node with an IP opens http://<ip> in a new tab.
|
* 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 {
|
import {
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
@@ -28,8 +28,9 @@ import { THEMES } from '@/utils/themes'
|
|||||||
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { liveviewApi } from '@/api/client'
|
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 STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -40,6 +41,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme)
|
||||||
|
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
// - standalone → always ready (localStorage, no key required)
|
// - standalone → always ready (localStorage, no key required)
|
||||||
@@ -73,9 +76,12 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.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(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
@@ -87,7 +93,7 @@ function LiveViewCanvas() {
|
|||||||
const detail: string = err.response.data?.detail ?? ''
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitViewPending || nodes.length === 0) return
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
@@ -103,6 +109,18 @@ function LiveViewCanvas() {
|
|||||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
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') {
|
if (viewState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
||||||
@@ -131,8 +149,8 @@ function LiveViewCanvas() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={visibleNodes}
|
||||||
edges={edges}
|
edges={visibleEdges}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
nodesDraggable={false}
|
nodesDraggable={false}
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
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 ───────────────────────────────────────────────
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show any Access Denied when key is valid', async () => {
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useViewport } from '@xyflow/react'
|
||||||
|
import type { Guide } from '@/utils/alignment'
|
||||||
|
|
||||||
|
interface AlignmentGuidesProps {
|
||||||
|
guides: Guide[]
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG overlay that draws alignment guide lines on top of the React Flow canvas.
|
||||||
|
* Coordinates are in canvas (flow) space; we read the viewport transform to
|
||||||
|
* project them into screen space so lines stay locked to nodes when the user
|
||||||
|
* pans or zooms.
|
||||||
|
*/
|
||||||
|
export function AlignmentGuides({ guides, color = '#00d4ff' }: AlignmentGuidesProps) {
|
||||||
|
const { x: vx, y: vy, zoom } = useViewport()
|
||||||
|
|
||||||
|
if (guides.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 5,
|
||||||
|
overflow: 'visible',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{guides.map((g, i) => {
|
||||||
|
if (g.axis === 'x') {
|
||||||
|
const x = g.position * zoom + vx
|
||||||
|
const y1 = g.start * zoom + vy
|
||||||
|
const y2 = g.end * zoom + vy
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`x-${i}-${g.position}`}
|
||||||
|
x1={x}
|
||||||
|
y1={y1}
|
||||||
|
x2={x}
|
||||||
|
y2={y2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const y = g.position * zoom + vy
|
||||||
|
const x1 = g.start * zoom + vx
|
||||||
|
const x2 = g.end * zoom + vx
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={`y-${i}-${g.position}`}
|
||||||
|
x1={x1}
|
||||||
|
y1={y}
|
||||||
|
x2={x2}
|
||||||
|
y2={y}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeDasharray="4 3"
|
||||||
|
shapeRendering="crispEdges"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -17,9 +17,12 @@ import '@xyflow/react/dist/style.css'
|
|||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { nodeTypes } from './nodes/nodeTypes'
|
import { nodeTypes } from './nodes/nodeTypes'
|
||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
interface CanvasContainerProps {
|
interface CanvasContainerProps {
|
||||||
@@ -53,6 +56,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[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>) => {
|
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
setSelectedNode(null)
|
setSelectedNode(null)
|
||||||
@@ -83,11 +97,13 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={visibleNodes}
|
||||||
edges={edges}
|
edges={visibleEdges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnectProp}
|
onConnect={onConnectProp}
|
||||||
@@ -96,6 +112,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={onNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
@@ -121,6 +139,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
<SearchBar onOpenPending={onOpenPending} />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
|
<AlignmentGuides guides={guides} />
|
||||||
<Controls>
|
<Controls>
|
||||||
<ControlButton
|
<ControlButton
|
||||||
onClick={() => setLassoMode((m) => !m)}
|
onClick={() => setLassoMode((m) => !m)}
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
|
|
||||||
const pendingResults = q
|
const pendingResults = q
|
||||||
? pendingDevices.filter((d) =>
|
? pendingDevices.filter((d) =>
|
||||||
d.ip.toLowerCase().includes(q) ||
|
d.ip?.toLowerCase().includes(q) ||
|
||||||
d.hostname?.toLowerCase().includes(q) ||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.friendly_name?.toLowerCase().includes(q) ||
|
||||||
|
d.ieee_address?.toLowerCase().includes(q) ||
|
||||||
d.services.some((s) =>
|
d.services.some((s) =>
|
||||||
s.service_name?.toLowerCase().includes(q) ||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
s.category?.toLowerCase().includes(q)
|
s.category?.toLowerCase().includes(q)
|
||||||
@@ -196,10 +198,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
>
|
>
|
||||||
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
<span style={{ fontSize: 10, color: '#e3b341', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>pending</span>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{d.hostname ?? d.ip}
|
{d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
||||||
{serviceName ?? d.ip}
|
{serviceName ?? d.ip ?? d.ieee_address ?? ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { AlignmentGuides } from '../AlignmentGuides'
|
||||||
|
import type { Guide } from '@/utils/alignment'
|
||||||
|
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
useViewport: () => ({ x: 50, y: 100, zoom: 2 }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AlignmentGuides', () => {
|
||||||
|
it('renders nothing when no guides', () => {
|
||||||
|
const { container } = render(<AlignmentGuides guides={[]} />)
|
||||||
|
expect(container.querySelector('svg')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects an x-axis guide through the viewport transform', () => {
|
||||||
|
const guides: Guide[] = [{ axis: 'x', position: 100, start: 0, end: 200 }]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
const line = container.querySelector('line')!
|
||||||
|
// x = position * zoom + vx → 100*2 + 50 = 250
|
||||||
|
expect(line.getAttribute('x1')).toBe('250')
|
||||||
|
expect(line.getAttribute('x2')).toBe('250')
|
||||||
|
// y1 = start * zoom + vy → 0*2 + 100 = 100; y2 = 200*2 + 100 = 500
|
||||||
|
expect(line.getAttribute('y1')).toBe('100')
|
||||||
|
expect(line.getAttribute('y2')).toBe('500')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('projects a y-axis guide horizontally', () => {
|
||||||
|
const guides: Guide[] = [{ axis: 'y', position: 50, start: 10, end: 60 }]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
const line = container.querySelector('line')!
|
||||||
|
// y = 50*2 + 100 = 200; x1 = 10*2 + 50 = 70; x2 = 60*2 + 50 = 170
|
||||||
|
expect(line.getAttribute('y1')).toBe('200')
|
||||||
|
expect(line.getAttribute('y2')).toBe('200')
|
||||||
|
expect(line.getAttribute('x1')).toBe('70')
|
||||||
|
expect(line.getAttribute('x2')).toBe('170')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders one line per guide', () => {
|
||||||
|
const guides: Guide[] = [
|
||||||
|
{ axis: 'x', position: 100, start: 0, end: 200 },
|
||||||
|
{ axis: 'y', position: 50, start: 10, end: 60 },
|
||||||
|
]
|
||||||
|
const { container } = render(<AlignmentGuides guides={guides} />)
|
||||||
|
expect(container.querySelectorAll('line')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -44,11 +44,13 @@ vi.mock('@/utils/nodeColors', () => ({
|
|||||||
|
|
||||||
vi.mock('@/utils/nodeIcons', () => ({
|
vi.mock('@/utils/nodeIcons', () => ({
|
||||||
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
resolveNodeIcon: (_typeIcon: unknown) => _typeIcon,
|
||||||
|
isBrandIconKey: (k: string | undefined) => !!k && k.startsWith('brand:'),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/maskIp', () => ({
|
vi.mock('@/utils/maskIp', () => ({
|
||||||
maskIp: (ip: string) => ip,
|
maskIp: (ip: string) => ip,
|
||||||
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
splitIps: (ip: string) => ip ? ip.split(',').map((s: string) => s.trim()).filter(Boolean) : [],
|
||||||
|
primaryIp: (ip: string) => ip ? ip.split(',')[0].trim() : '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/propertyIcons', () => ({
|
vi.mock('@/utils/propertyIcons', () => ({
|
||||||
@@ -56,8 +58,12 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
vi.mock('@/utils/handleUtils', () => ({
|
||||||
BOTTOM_HANDLE_IDS: ['bottom'],
|
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
||||||
BOTTOM_HANDLE_POSITIONS: { 1: [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,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
beforeEach(() => { mockZoom = 1 })
|
beforeEach(() => { mockZoom = 1 })
|
||||||
@@ -168,6 +174,72 @@ 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' }] })
|
||||||
|
expect(screen.queryByTitle('Show services')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders service rows when services are toggled on', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [
|
||||||
|
{ service_name: 'nginx', port: 80, protocol: 'tcp' },
|
||||||
|
{ service_name: 'ssh', port: 22, protocol: 'tcp' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('nginx')).toBeDefined()
|
||||||
|
expect(screen.getByText('80')).toBeDefined()
|
||||||
|
expect(screen.getByText('ssh')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders clickable service links for web services', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [{ service_name: 'nginx', port: 80, protocol: 'tcp' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const link = screen.getByRole('link', { name: /nginx/i }) as HTMLAnchorElement
|
||||||
|
expect(link.getAttribute('href')).toBe('http://192.168.1.10:80')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps non-web services as non-clickable rows', () => {
|
||||||
|
renderBaseNode({
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
custom_colors: { show_services: true },
|
||||||
|
services: [{ service_name: 'ssh', port: 22, protocol: 'tcp' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.queryByRole('link', { name: /ssh/i })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('BaseNode — legacy hardware fallback', () => {
|
describe('BaseNode — legacy hardware fallback', () => {
|
||||||
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
it('renders legacy hardware when properties is undefined and show_hardware is true', () => {
|
||||||
renderBaseNode({
|
renderBaseNode({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
NodeResizer: ({ isVisible }: { isVisible: boolean }) => (
|
||||||
<div data-testid="node-resizer" data-visible={isVisible} />
|
<div data-testid="node-resizer" data-visible={isVisible} />
|
||||||
),
|
),
|
||||||
|
Handle: () => null,
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({}),
|
useReactFlow: () => ({}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ function renderGroupNode(props: Partial<Parameters<typeof GroupNode>[0]> = {}, s
|
|||||||
nodes: storeNodes,
|
nodes: storeNodes,
|
||||||
updateNode: vi.fn(),
|
updateNode: vi.fn(),
|
||||||
snapshotHistory: vi.fn(),
|
snapshotHistory: vi.fn(),
|
||||||
|
toggleNodeCollapsed: vi.fn(),
|
||||||
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
@@ -132,4 +135,51 @@ describe('GroupNode', () => {
|
|||||||
renderGroupNode()
|
renderGroupNode()
|
||||||
expect(screen.queryByText(/●/)).toBeNull()
|
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,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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from 'vitest'
|
||||||
import { buildWaypointPath, distToSegment, findInsertIndex, snap45, snap45both } from '../waypointUtils'
|
import { buildWaypointPath, distToSegment, findInsertIndex, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from '../waypointUtils'
|
||||||
|
|
||||||
describe('buildWaypointPath — bezier (default)', () => {
|
describe('buildWaypointPath — bezier (default)', () => {
|
||||||
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
it('builds a catmull-rom curve with no waypoints (start = end clamp)', () => {
|
||||||
@@ -173,3 +173,35 @@ describe('findInsertIndex', () => {
|
|||||||
expect(idx).toBe(2)
|
expect(idx).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('getWaypointLabelPosition', () => {
|
||||||
|
it('uses the routed midpoint for a symmetric bezier waypoint path', () => {
|
||||||
|
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 100 }], 100, 0)
|
||||||
|
expect(point.x).toBeCloseTo(50, 0)
|
||||||
|
expect(point.y).toBeCloseTo(100, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the routed midpoint for a smooth waypoint path', () => {
|
||||||
|
const point = getWaypointLabelPosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 'smooth')
|
||||||
|
expect(point.x).toBeCloseTo(50, 0)
|
||||||
|
expect(point.y).toBeCloseTo(50, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the source point when the path is degenerate', () => {
|
||||||
|
const point = getWaypointLabelPosition(10, 20, [], 10, 20, 'smooth')
|
||||||
|
expect(point).toEqual({ x: 10, y: 20 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getAddWaypointHandlePosition', () => {
|
||||||
|
it('places bezier add handle on the rendered curved segment', () => {
|
||||||
|
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 100 }], 100, 0, 0, 'bezier')
|
||||||
|
expect(point.x).toBeCloseTo(21.875, 3)
|
||||||
|
expect(point.y).toBeCloseTo(56.25, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps smooth add handle at straight segment midpoint', () => {
|
||||||
|
const point = getAddWaypointHandlePosition(0, 0, [{ x: 50, y: 0 }, { x: 50, y: 100 }], 100, 100, 1, 'smooth')
|
||||||
|
expect(point).toEqual({ x: 50, y: 50 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -7,4 +7,6 @@ export const edgeTypes = {
|
|||||||
vlan: HomelableEdge,
|
vlan: HomelableEdge,
|
||||||
virtual: HomelableEdge,
|
virtual: HomelableEdge,
|
||||||
cluster: HomelableEdge,
|
cluster: HomelableEdge,
|
||||||
|
fibre: HomelableEdge,
|
||||||
|
electrical: HomelableEdge,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
EdgeLabelRenderer,
|
EdgeLabelRenderer,
|
||||||
@@ -13,7 +13,7 @@ import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { buildWaypointPath, snap45, snap45both } from './waypointUtils'
|
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
|
|
||||||
@@ -161,9 +161,9 @@ function segmentMidpoints(
|
|||||||
const isSmooth = pathStyle === 'smooth'
|
const isSmooth = pathStyle === 'smooth'
|
||||||
|
|
||||||
return pts.slice(0, -1).map((a, i) => {
|
return pts.slice(0, -1).map((a, i) => {
|
||||||
const b = pts[i + 1]
|
const base = getAddWaypointHandlePosition(sourceX, sourceY, waypoints, targetX, targetY, i, pathStyle)
|
||||||
let mx = (a.x + b.x) / 2
|
let mx = base.x
|
||||||
const my = (a.y + b.y) / 2
|
const my = base.y
|
||||||
|
|
||||||
// For smooth style with no existing waypoints, bias the single + handle onto
|
// For smooth style with no existing waypoints, bias the single + handle onto
|
||||||
// the source handle axis so clicking it creates a perpendicular exit.
|
// the source handle axis so clicking it creates a perpendicular exit.
|
||||||
@@ -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 ──────────────────────────────────────────────────────
|
// ── 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 activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
const sourceType = useStore((s) => s.nodeLookup.get(source)?.type)
|
||||||
@@ -205,8 +309,9 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
? buildWaypointPath(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
: autoPath
|
: autoPath
|
||||||
|
|
||||||
const midX = hasWaypoints ? (sourceX + targetX) / 2 : labelX
|
const labelPosition = hasWaypoints
|
||||||
const midY = (sourceY + targetY) / 2
|
? getWaypointLabelPosition(sourceX, sourceY, waypoints, targetX, targetY, pathStyle)
|
||||||
|
: { x: labelX, y: (sourceY + targetY) / 2 }
|
||||||
|
|
||||||
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
const edgeType: EdgeType = data?.type ?? 'ethernet'
|
||||||
const edgeColors = theme.colors.edgeColors
|
const edgeColors = theme.colors.edgeColors
|
||||||
@@ -218,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
vlan: { strokeWidth: 2.5 },
|
vlan: { strokeWidth: 2.5 },
|
||||||
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
virtual: { stroke: edgeColors.virtual, strokeWidth: 1, strokeDasharray: '4 4' },
|
||||||
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
cluster: { stroke: edgeColors.cluster, strokeWidth: 2.5, strokeDasharray: '8 3' },
|
||||||
|
fibre: { stroke: edgeColors.fibre, strokeWidth: 2.5, filter: `drop-shadow(0 0 3px ${edgeColors.fibre}aa)` },
|
||||||
|
electrical: { stroke: edgeColors.electrical, strokeWidth: 2 },
|
||||||
}
|
}
|
||||||
|
|
||||||
const customColor = data?.custom_color as string | undefined
|
const customColor = data?.custom_color as string | undefined
|
||||||
@@ -300,7 +407,7 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${midX}px, ${midY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
color: theme.colors.edgeLabelColor,
|
color: theme.colors.edgeLabelColor,
|
||||||
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
border: `1px solid ${theme.colors.edgeLabelBorder}`,
|
||||||
@@ -310,6 +417,38 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
</div>
|
</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 */}
|
{/* Existing waypoint drag handles */}
|
||||||
{selected && waypoints.map((wp, idx) => {
|
{selected && waypoints.map((wp, idx) => {
|
||||||
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
const prevPoint = idx === 0 ? { x: sourceX, y: sourceY } : waypoints[idx - 1]
|
||||||
|
|||||||
@@ -72,6 +72,216 @@ export function buildWaypointPath(
|
|||||||
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
return pathStyle === 'smooth' ? buildRoundedPolylinePath(pts) : buildCatmullRomPath(pts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interpolateLine(a: Waypoint, b: Waypoint, t: number): Waypoint {
|
||||||
|
return {
|
||||||
|
x: a.x + (b.x - a.x) * t,
|
||||||
|
y: a.y + (b.y - a.y) * t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateQuadratic(a: Waypoint, b: Waypoint, c: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * a.x + 2 * mt * t * b.x + t * t * c.x,
|
||||||
|
y: mt * mt * a.y + 2 * mt * t * b.y + t * t * c.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateCubic(a: Waypoint, b: Waypoint, c: Waypoint, d: Waypoint, t: number): Waypoint {
|
||||||
|
const mt = 1 - t
|
||||||
|
return {
|
||||||
|
x: mt * mt * mt * a.x + 3 * mt * mt * t * b.x + 3 * mt * t * t * c.x + t * t * t * d.x,
|
||||||
|
y: mt * mt * mt * a.y + 3 * mt * mt * t * b.y + 3 * mt * t * t * c.y + t * t * t * d.y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approximateLength(pointAt: (t: number) => Waypoint, steps = 24): number {
|
||||||
|
let length = 0
|
||||||
|
let prev = pointAt(0)
|
||||||
|
|
||||||
|
for (let step = 1; step <= steps; step++) {
|
||||||
|
const next = pointAt(step / steps)
|
||||||
|
length += Math.hypot(next.x - prev.x, next.y - prev.y)
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return length
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathSegment = {
|
||||||
|
length: number
|
||||||
|
pointAt: (t: number) => Waypoint
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBezierSegments(pts: Waypoint[]): PathSegment[] {
|
||||||
|
if (pts.length < 2) return []
|
||||||
|
|
||||||
|
return pts.slice(0, -1).map((_, i) => {
|
||||||
|
const p0 = pts[Math.max(i - 1, 0)]
|
||||||
|
const p1 = pts[i]
|
||||||
|
const p2 = pts[i + 1]
|
||||||
|
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||||
|
const cp1 = {
|
||||||
|
x: p1.x + (p2.x - p0.x) / 6,
|
||||||
|
y: p1.y + (p2.y - p0.y) / 6,
|
||||||
|
}
|
||||||
|
const cp2 = {
|
||||||
|
x: p2.x - (p3.x - p1.x) / 6,
|
||||||
|
y: p2.y - (p3.y - p1.y) / 6,
|
||||||
|
}
|
||||||
|
const pointAt = (t: number) => interpolateCubic(p1, cp1, cp2, p2, t)
|
||||||
|
|
||||||
|
return {
|
||||||
|
length: approximateLength(pointAt),
|
||||||
|
pointAt,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSmoothSegments(pts: Waypoint[], radius = 8): PathSegment[] {
|
||||||
|
if (pts.length < 2) return []
|
||||||
|
if (pts.length === 2) {
|
||||||
|
const pointAt = (t: number) => interpolateLine(pts[0], pts[1], t)
|
||||||
|
return [{ length: Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y), pointAt }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments: PathSegment[] = []
|
||||||
|
let cursor = pts[0]
|
||||||
|
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
|
const prev = pts[i - 1]
|
||||||
|
const curr = pts[i]
|
||||||
|
const next = pts[i + 1]
|
||||||
|
|
||||||
|
const dx1 = curr.x - prev.x
|
||||||
|
const dy1 = curr.y - prev.y
|
||||||
|
const len1 = Math.hypot(dx1, dy1)
|
||||||
|
|
||||||
|
const dx2 = next.x - curr.x
|
||||||
|
const dy2 = next.y - curr.y
|
||||||
|
const len2 = Math.hypot(dx2, dy2)
|
||||||
|
|
||||||
|
if (len1 < 1 || len2 < 1) {
|
||||||
|
const start = { x: cursor.x, y: cursor.y }
|
||||||
|
const end = { x: curr.x, y: curr.y }
|
||||||
|
const lineToCurr = (t: number) => interpolateLine(start, end, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(curr.x - cursor.x, curr.y - cursor.y),
|
||||||
|
pointAt: lineToCurr,
|
||||||
|
})
|
||||||
|
cursor = curr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = Math.min(radius, len1 / 2, len2 / 2)
|
||||||
|
const before = {
|
||||||
|
x: curr.x - (dx1 / len1) * r,
|
||||||
|
y: curr.y - (dy1 / len1) * r,
|
||||||
|
}
|
||||||
|
const after = {
|
||||||
|
x: curr.x + (dx2 / len2) * r,
|
||||||
|
y: curr.y + (dy2 / len2) * r,
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineStart = { x: cursor.x, y: cursor.y }
|
||||||
|
const lineEnd = { x: before.x, y: before.y }
|
||||||
|
const lineToBefore = (t: number) => interpolateLine(lineStart, lineEnd, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(before.x - cursor.x, before.y - cursor.y),
|
||||||
|
pointAt: lineToBefore,
|
||||||
|
})
|
||||||
|
|
||||||
|
const curveAroundCorner = (t: number) => interpolateQuadratic(before, curr, after, t)
|
||||||
|
segments.push({
|
||||||
|
length: approximateLength(curveAroundCorner),
|
||||||
|
pointAt: curveAroundCorner,
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor = after
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetStart = { x: cursor.x, y: cursor.y }
|
||||||
|
const targetEnd = { x: pts[pts.length - 1].x, y: pts[pts.length - 1].y }
|
||||||
|
const lineToTarget = (t: number) => interpolateLine(targetStart, targetEnd, t)
|
||||||
|
segments.push({
|
||||||
|
length: Math.hypot(pts[pts.length - 1].x - cursor.x, pts[pts.length - 1].y - cursor.y),
|
||||||
|
pointAt: lineToTarget,
|
||||||
|
})
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWaypointLabelPosition(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
pathStyle: string = 'bezier',
|
||||||
|
): Waypoint {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
const segments = pathStyle === 'smooth' ? buildSmoothSegments(pts) : buildBezierSegments(pts)
|
||||||
|
|
||||||
|
if (segments.length === 0) return pts[0]
|
||||||
|
|
||||||
|
const totalLength = segments.reduce((sum, segment) => sum + segment.length, 0)
|
||||||
|
if (totalLength <= 0) return pts[Math.floor(pts.length / 2)]
|
||||||
|
|
||||||
|
let remaining = totalLength / 2
|
||||||
|
for (const segment of segments) {
|
||||||
|
if (remaining <= segment.length) {
|
||||||
|
const t = segment.length === 0 ? 0 : remaining / segment.length
|
||||||
|
return segment.pointAt(t)
|
||||||
|
}
|
||||||
|
remaining -= segment.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = segments[segments.length - 1]
|
||||||
|
return lastSegment.pointAt(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBezierSegmentPoint(
|
||||||
|
pts: Waypoint[],
|
||||||
|
insertIndex: number,
|
||||||
|
t: number,
|
||||||
|
): Waypoint {
|
||||||
|
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||||
|
const p0 = pts[Math.max(i - 1, 0)]
|
||||||
|
const p1 = pts[i]
|
||||||
|
const p2 = pts[i + 1]
|
||||||
|
const p3 = pts[Math.min(i + 2, pts.length - 1)]
|
||||||
|
const cp1 = {
|
||||||
|
x: p1.x + (p2.x - p0.x) / 6,
|
||||||
|
y: p1.y + (p2.y - p0.y) / 6,
|
||||||
|
}
|
||||||
|
const cp2 = {
|
||||||
|
x: p2.x - (p3.x - p1.x) / 6,
|
||||||
|
y: p2.y - (p3.y - p1.y) / 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
return interpolateCubic(p1, cp1, cp2, p2, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAddWaypointHandlePosition(
|
||||||
|
sourceX: number, sourceY: number,
|
||||||
|
waypoints: Waypoint[],
|
||||||
|
targetX: number, targetY: number,
|
||||||
|
insertIndex: number,
|
||||||
|
pathStyle: string = 'bezier',
|
||||||
|
): Waypoint {
|
||||||
|
const pts = [{ x: sourceX, y: sourceY }, ...waypoints, { x: targetX, y: targetY }]
|
||||||
|
|
||||||
|
if (pts.length < 2) return { x: sourceX, y: sourceY }
|
||||||
|
|
||||||
|
if (pathStyle !== 'smooth') {
|
||||||
|
return getBezierSegmentPoint(pts, insertIndex, 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
const i = Math.max(0, Math.min(insertIndex, pts.length - 2))
|
||||||
|
return {
|
||||||
|
x: (pts[i].x + pts[i + 1].x) / 2,
|
||||||
|
y: (pts[i].y + pts[i + 1].y) / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
// ── 45° snapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { createElement, useEffect, useMemo } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { BOTTOM_HANDLE_IDS, BOTTOM_HANDLE_POSITIONS } from '@/utils/handleUtils'
|
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
||||||
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
@@ -35,6 +37,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
|
const services = data.services ?? []
|
||||||
|
const showServices = data.custom_colors?.show_services === true
|
||||||
|
const serviceHost = data.ip ? primaryIp(data.ip) : data.hostname
|
||||||
|
|
||||||
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
// Properties: prefer new system; fall back to legacy hardware fields for unmigrated nodes
|
||||||
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
const visibleProperties = data.properties?.filter((p) => p.visible) ?? null
|
||||||
@@ -56,7 +61,8 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
||||||
|
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
||||||
width: width ? '100%' : undefined,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -66,7 +72,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
@@ -76,6 +82,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
|
|
||||||
|
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||||
|
<div
|
||||||
|
className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ backgroundColor: statusColor }}
|
||||||
|
title={data.status}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
<div className="flex flex-row items-center gap-2.5 px-2.5 py-2 min-w-0 overflow-hidden">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
@@ -86,7 +99,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createElement(resolvedIcon, { size: 15 })}
|
{isBrandIconKey(data.custom_icon)
|
||||||
|
? <NodeIcon typeIcon={typeIcon} customIconKey={data.custom_icon} size={15} />
|
||||||
|
: createElement(resolvedIcon, { size: 15 })}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label + IP */}
|
{/* Label + IP */}
|
||||||
@@ -121,7 +136,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
return (
|
return (
|
||||||
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -130,6 +145,74 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showServices && services.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div style={{ height: 1, background: `${colors.border}44`, margin: '0 8px' }} />
|
||||||
|
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
|
||||||
|
{services.map((svc, idx) => {
|
||||||
|
const url = getServiceUrl(svc, serviceHost)
|
||||||
|
const row = (
|
||||||
|
<div
|
||||||
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||||
|
style={{
|
||||||
|
background: theme.colors.nodeIconBackground,
|
||||||
|
color: theme.colors.nodeSubtextColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2 w-full min-w-0">
|
||||||
|
{/* LEFT: service name */}
|
||||||
|
<span
|
||||||
|
className="font-medium truncate"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
title={svc.service_name}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* RIGHT: path + port */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0 min-w-0">
|
||||||
|
{svc.path && (
|
||||||
|
<span
|
||||||
|
className="truncate text-[#8b949e] text-right max-w-[80px]"
|
||||||
|
title={svc.path}
|
||||||
|
>
|
||||||
|
{svc.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="font-mono opacity-80 flex items-center gap-1">
|
||||||
|
<span>{svc.port}</span>
|
||||||
|
<ExternalLink
|
||||||
|
size={9}
|
||||||
|
className={`shrink-0 ${url ? '' : 'opacity-0'}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!url) return <div key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}>{row}</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${svc.port}-${svc.protocol}-${svc.service_name}-${idx}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block hover:opacity-85 transition-opacity"
|
||||||
|
title={url}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{row}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
{/* Legacy hardware section — fallback for nodes not yet migrated */}
|
||||||
{showLegacyHardware && (
|
{showLegacyHardware && (
|
||||||
<>
|
<>
|
||||||
@@ -139,7 +222,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
<div className="flex items-center gap-1 font-mono text-[10px]" style={{ color: theme.colors.nodeSubtextColor }}>
|
||||||
<Cpu size={9} className="shrink-0" />
|
<Cpu size={9} className="shrink-0" />
|
||||||
{data.cpu_model && (
|
{data.cpu_model && (
|
||||||
<span className="truncate max-w-[80px]" title={data.cpu_model}>{data.cpu_model}</span>
|
<span className="truncate max-w-20" title={data.cpu_model}>{data.cpu_model}</span>
|
||||||
)}
|
)}
|
||||||
{data.cpu_count != null && (
|
{data.cpu_count != null && (
|
||||||
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
<span className="shrink-0">{data.cpu_model ? `· ${data.cpu_count}c` : `${data.cpu_count} cores`}</span>
|
||||||
@@ -166,18 +249,25 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Status dot */}
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
<div
|
const sourceId = bottomHandleId(idx)
|
||||||
className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full shrink-0"
|
const targetId = `${sourceId}-t`
|
||||||
style={{ backgroundColor: statusColor }}
|
|
||||||
title={data.status}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(BOTTOM_HANDLE_POSITIONS[data.bottom_handles ?? 1] ?? BOTTOM_HANDLE_POSITIONS[1]).map((leftPct, idx) => {
|
|
||||||
const sourceId = BOTTOM_HANDLE_IDS[idx]
|
|
||||||
const targetId = idx === 0 ? 'bottom-t' : `bottom-${idx + 1}-t`
|
|
||||||
return (
|
return (
|
||||||
<span key={sourceId}>
|
<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
|
<Handle
|
||||||
type="source"
|
type="source"
|
||||||
position={Position.Bottom}
|
position={Position.Bottom}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { type NodeProps, type Node, NodeResizer } from '@xyflow/react'
|
import { type NodeProps, type Node, NodeResizer, Handle, Position } from '@xyflow/react'
|
||||||
import { Layers, Pencil, Check, X } from 'lucide-react'
|
import { Layers, Pencil, Check, X, ChevronDown } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
import { STATUS_COLORS, type NodeData } from '@/types'
|
import { STATUS_COLORS, type NodeData } from '@/types'
|
||||||
|
|
||||||
export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
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 showBorder = data.custom_colors?.show_border !== false
|
||||||
const isVisible = showBorder || selected
|
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 }}
|
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 */}
|
{/* Header */}
|
||||||
{isVisible && (
|
{isVisible && (
|
||||||
<div
|
<div
|
||||||
@@ -111,6 +139,28 @@ export function GroupNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
|||||||
</button>
|
</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 */}
|
{/* Status summary */}
|
||||||
{children.length > 0 && (
|
{children.length > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 10, flexShrink: 0, marginLeft: 4 }}>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
|
import { ChevronDown } from 'lucide-react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { getZoneSpatialChildren } from '@/utils/collapseFilter'
|
||||||
import type { NodeData, TextPosition } from '@/types'
|
import type { NodeData, TextPosition } from '@/types'
|
||||||
|
|
||||||
const FONT_FAMILIES: Record<string, string> = {
|
const FONT_FAMILIES: Record<string, string> = {
|
||||||
@@ -36,9 +38,12 @@ const HANDLE_SIDES = [
|
|||||||
|
|
||||||
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
const setEditingGroupRectId = useCanvasStore((s) => s.setEditingGroupRectId)
|
||||||
|
const toggleNodeCollapsed = useCanvasStore((s) => s.toggleNodeCollapsed)
|
||||||
|
const nodes = useCanvasStore((s) => s.nodes)
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
|
|
||||||
const rc = data.custom_colors ?? {}
|
const rc = data.custom_colors ?? {}
|
||||||
|
const isCollapsed = data.collapsed ?? false
|
||||||
const borderColor = rc.border ?? '#00d4ff'
|
const borderColor = rc.border ?? '#00d4ff'
|
||||||
const borderStyle = rc.border_style ?? 'solid'
|
const borderStyle = rc.border_style ?? 'solid'
|
||||||
const borderWidth = rc.border_width ?? 2
|
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 textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||||
const posStyle = POSITION_STYLES[textPos]
|
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'
|
const outsideJustify = textPos.includes('right') ? 'flex-end'
|
||||||
: (textPos.includes('center') || textPos === 'center') ? 'center'
|
: (textPos.includes('center') || textPos === 'center') ? 'center'
|
||||||
: 'flex-start'
|
: 'flex-start'
|
||||||
@@ -118,6 +130,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
|
transition: 'opacity 0.2s ease-out, filter 0.2s ease-out',
|
||||||
|
opacity: isCollapsed ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={() => setHovered(true)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
@@ -126,6 +140,51 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
setEditingGroupRectId(id)
|
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 && (
|
{labelPosition === 'outside' && data.label && (
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { createElement } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { resolveNodeIcon } from '@/utils/nodeIcons'
|
import { resolveNodeIcon, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
import { NodeIcon } from '@/components/ui/NodeIcon'
|
||||||
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
import { resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
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 activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
@@ -53,7 +60,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
minHeight={160}
|
minHeight={160}
|
||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
lineStyle={{ borderColor: glow, opacity: 0.6 }}
|
||||||
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground }}
|
handleStyle={{ borderColor: glow, backgroundColor: theme.colors.nodeCardBackground, width: 6, height: 6 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Group border */}
|
{/* Group border */}
|
||||||
@@ -71,7 +78,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2 px-2.5 py-1.5 shrink-0"
|
className="flex flex-row items-start gap-2 px-2.5 py-1.5 shrink-0"
|
||||||
style={{
|
style={{
|
||||||
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
background: isOnline ? `${glow}18` : `${theme.colors.nodeIconBackground}88`,
|
||||||
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
borderBottom: `1px solid ${isOnline ? `${glow}33` : theme.colors.handleBackground}`,
|
||||||
@@ -84,7 +91,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
background: theme.colors.nodeIconBackground,
|
background: theme.colors.nodeIconBackground,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{createElement(resolvedIcon, { size: 12 })}
|
{isBrandIconKey(data.custom_icon)
|
||||||
|
? <NodeIcon typeIcon={Layers} customIconKey={data.custom_icon} size={12} />
|
||||||
|
: createElement(resolvedIcon, { size: 12 })}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 flex-1">
|
<div className="flex flex-col min-w-0 flex-1">
|
||||||
<span
|
<span
|
||||||
@@ -93,18 +102,19 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
>
|
>
|
||||||
{data.label}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
{data.ip && (
|
{data.ip && splitIps(data.ip).map((ip) => (
|
||||||
<span
|
<span
|
||||||
|
key={ip}
|
||||||
className="font-mono text-[9px] truncate"
|
className="font-mono text-[9px] truncate"
|
||||||
style={{ color: theme.colors.nodeSubtextColor }}
|
style={{ color: theme.colors.nodeSubtextColor }}
|
||||||
>
|
>
|
||||||
{data.ip}
|
{hideIp ? maskIp(ip) : ip}
|
||||||
</span>
|
</span>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Status dot */}
|
{/* Status dot */}
|
||||||
<div
|
<div
|
||||||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
className="ml-auto w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
style={{ backgroundColor: statusColor }}
|
style={{ backgroundColor: statusColor }}
|
||||||
title={data.status}
|
title={data.status}
|
||||||
/>
|
/>
|
||||||
@@ -125,7 +135,7 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Icon && <Icon size={9} className="shrink-0" />}
|
{Icon && <Icon size={9} className="shrink-0" />}
|
||||||
<span className="truncate max-w-[60px] shrink-0" title={prop.key}>{prop.key}</span>
|
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
|
||||||
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -142,13 +152,26 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
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="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
||||||
<Handle
|
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
||||||
type="source"
|
const sourceId = bottomHandleId(idx)
|
||||||
position={Position.Bottom}
|
const targetId = `${sourceId}-t`
|
||||||
id="bottom"
|
return (
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
<span key={sourceId}>
|
||||||
/>
|
<Handle
|
||||||
<Handle type="target" position={Position.Bottom} id="bottom-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
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 */}
|
{/* Cluster handles */}
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NodeResizer, type NodeProps, type Node } from '@xyflow/react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
const FONT_FAMILIES: Record<string, string> = {
|
||||||
|
inter: 'Inter, sans-serif',
|
||||||
|
mono: '"JetBrains Mono", monospace',
|
||||||
|
serif: 'Georgia, serif',
|
||||||
|
sans: 'system-ui, sans-serif',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextNode({ id, data, selected }: NodeProps<Node<NodeData>>) {
|
||||||
|
const setEditingTextId = useCanvasStore((s) => s.setEditingTextId)
|
||||||
|
|
||||||
|
const rc = data.custom_colors ?? {}
|
||||||
|
const borderColor = rc.border ?? '#30363d'
|
||||||
|
const borderStyle = rc.border_style ?? 'none'
|
||||||
|
const borderWidth = rc.border_width ?? 1
|
||||||
|
const backgroundColor = rc.background ?? 'transparent'
|
||||||
|
const textColor = rc.text_color ?? '#e6edf3'
|
||||||
|
const textSize: number = rc.text_size ?? 14
|
||||||
|
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||||
|
|
||||||
|
const content = data.text_content ?? data.label ?? ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NodeResizer
|
||||||
|
isVisible={selected}
|
||||||
|
minWidth={40}
|
||||||
|
minHeight={20}
|
||||||
|
handleStyle={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: '#00d4ff',
|
||||||
|
border: '1px solid #0d1117',
|
||||||
|
}}
|
||||||
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 8,
|
||||||
|
background: backgroundColor,
|
||||||
|
border: borderStyle === 'none' ? 'none' : `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||||
|
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: 'default',
|
||||||
|
color: textColor,
|
||||||
|
fontFamily,
|
||||||
|
fontSize: textSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setEditingTextId(id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import type { NodeData, NodeProperty } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: 'pve-01',
|
||||||
|
type: 'proxmox',
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 'p1',
|
||||||
|
data: fullData,
|
||||||
|
selected,
|
||||||
|
type: 'proxmox',
|
||||||
|
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>
|
||||||
|
<ProxmoxGroupNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProxmoxGroupNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ hideIp: false })
|
||||||
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the node label', () => {
|
||||||
|
const { getByText } = renderNode({ label: 'My Proxmox' })
|
||||||
|
expect(getByText('My Proxmox')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders ip when provided', () => {
|
||||||
|
const { getByText } = renderNode({ ip: '192.168.1.10' })
|
||||||
|
expect(getByText('192.168.1.10')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders multiple ips when comma separated', () => {
|
||||||
|
const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
|
||||||
|
expect(getByText('10.0.0.1')).toBeDefined()
|
||||||
|
expect(getByText('10.0.0.2')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('masks ip when hideIp is enabled in store', () => {
|
||||||
|
useCanvasStore.setState({ hideIp: true })
|
||||||
|
const { queryByText } = renderNode({ ip: '192.168.1.10' })
|
||||||
|
expect(queryByText('192.168.1.10')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders visible properties only', () => {
|
||||||
|
const properties: NodeProperty[] = [
|
||||||
|
{ key: 'CPU', value: '16 cores', icon: null, visible: true },
|
||||||
|
{ key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
|
||||||
|
]
|
||||||
|
const { getByText, queryByText } = renderNode({ properties })
|
||||||
|
expect(getByText('CPU')).toBeDefined()
|
||||||
|
expect(getByText(/16 cores/)).toBeDefined()
|
||||||
|
expect(queryByText('Hidden')).toBeNull()
|
||||||
|
expect(queryByText(/should-not-show/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders status dot with title matching status', () => {
|
||||||
|
const { container } = renderNode({ status: 'offline' })
|
||||||
|
const dot = container.querySelector('[title="offline"]')
|
||||||
|
expect(dot).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
|
||||||
|
const { container } = renderNode({ container_mode: false })
|
||||||
|
// NodeResizer should not be present when not group-rendered
|
||||||
|
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container_mode default renders the group border container', () => {
|
||||||
|
const { container } = renderNode({})
|
||||||
|
// Group border div has rounded-xl border-2 classes
|
||||||
|
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)
|
||||||
|
const { container: nodeC } = renderNode({ container_mode: false })
|
||||||
|
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { render, fireEvent } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { TextNode } from '../TextNode'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import type { NodeProps, Node } from '@xyflow/react'
|
||||||
|
|
||||||
|
function renderNode(data: Partial<NodeData> = {}) {
|
||||||
|
const fullData: NodeData = {
|
||||||
|
label: '',
|
||||||
|
type: 'text',
|
||||||
|
status: 'unknown',
|
||||||
|
services: [],
|
||||||
|
text_content: 'Hello',
|
||||||
|
...data,
|
||||||
|
}
|
||||||
|
const props = {
|
||||||
|
id: 't1',
|
||||||
|
data: fullData,
|
||||||
|
selected: false,
|
||||||
|
type: 'text',
|
||||||
|
zIndex: 0,
|
||||||
|
isConnectable: true,
|
||||||
|
xPos: 0,
|
||||||
|
yPos: 0,
|
||||||
|
dragging: false,
|
||||||
|
deletable: true,
|
||||||
|
draggable: true,
|
||||||
|
selectable: true,
|
||||||
|
positionAbsoluteX: 0,
|
||||||
|
positionAbsoluteY: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 60,
|
||||||
|
dragHandle: undefined,
|
||||||
|
parentId: undefined,
|
||||||
|
sourcePosition: undefined,
|
||||||
|
targetPosition: undefined,
|
||||||
|
} as unknown as NodeProps<Node<NodeData>>
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<TextNode {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TextNode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useCanvasStore.setState({ editingTextId: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders text_content', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: 'My label' })
|
||||||
|
expect(getByText('My label')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to label when text_content is missing', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: undefined, label: 'Fallback' })
|
||||||
|
expect(getByText('Fallback')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('double-click sets editingTextId in store', () => {
|
||||||
|
const { getByText } = renderNode({ text_content: 'Edit me' })
|
||||||
|
fireEvent.doubleClick(getByText('Edit me'))
|
||||||
|
expect(useCanvasStore.getState().editingTextId).toBe('t1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { type NodeProps, type Node } from '@xyflow/react'
|
import { type NodeProps, type Node } from '@xyflow/react'
|
||||||
import {
|
import {
|
||||||
Globe, Router, Network, Server, Layers, Box, Container,
|
Globe, Router, Network, Server, Layers, Box, Container,
|
||||||
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, PlugZap, Anchor, Package,
|
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna,
|
||||||
|
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
@@ -10,6 +11,7 @@ type N = NodeProps<Node<NodeData>>
|
|||||||
|
|
||||||
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
export const IspNode = (props: N) => <BaseNode {...props} icon={Globe} />
|
||||||
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
export const RouterNode = (props: N) => <BaseNode {...props} icon={Router} />
|
||||||
|
export const FirewallNode = (props: N) => <BaseNode {...props} icon={Flame} />
|
||||||
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
export const SwitchNode = (props: N) => <BaseNode {...props} icon={Network} />
|
||||||
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
export const ServerNode = (props: N) => <BaseNode {...props} icon={Server} />
|
||||||
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
|
||||||
@@ -21,7 +23,29 @@ export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
|
|||||||
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
|
||||||
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
export const PrinterNode = (props: N) => <BaseNode {...props} icon={Printer} />
|
||||||
export const ComputerNode = (props: N) => <BaseNode {...props} icon={Monitor} />
|
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 CplNode = (props: N) => <BaseNode {...props} icon={PlugZap} />
|
||||||
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
export const DockerHostNode = (props: N) => <BaseNode {...props} icon={Anchor} />
|
||||||
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
export const DockerContainerNode = (props: N) => <BaseNode {...props} icon={Package} />
|
||||||
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
export const GenericNode = (props: N) => <BaseNode {...props} icon={Circle} />
|
||||||
|
// Zigbee node types
|
||||||
|
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,11 +1,21 @@
|
|||||||
import { IspNode, RouterNode, SwitchNode, ServerNode, VmNode, LxcNode, NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode } from './index'
|
import {
|
||||||
|
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
|
||||||
|
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
|
||||||
|
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
|
||||||
|
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
|
||||||
|
GridNode, UpsNode, BatteryNode, GeneratorNode, SolarPanelNode, InverterNode,
|
||||||
|
CircuitBreakerNode, ContactorNode, ElectricalSwitchNode, SocketNode,
|
||||||
|
LightNode, MeterNode, TransformerNode, LoadNode,
|
||||||
|
} from './index'
|
||||||
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
import { ProxmoxGroupNode } from './ProxmoxGroupNode'
|
||||||
import { GroupRectNode } from './GroupRectNode'
|
import { GroupRectNode } from './GroupRectNode'
|
||||||
import { GroupNode } from './GroupNode'
|
import { GroupNode } from './GroupNode'
|
||||||
|
import { TextNode } from './TextNode'
|
||||||
|
|
||||||
export const nodeTypes = {
|
export const nodeTypes = {
|
||||||
isp: IspNode,
|
isp: IspNode,
|
||||||
router: RouterNode,
|
router: RouterNode,
|
||||||
|
firewall: FirewallNode,
|
||||||
switch: SwitchNode,
|
switch: SwitchNode,
|
||||||
server: ServerNode,
|
server: ServerNode,
|
||||||
proxmox: ProxmoxGroupNode,
|
proxmox: ProxmoxGroupNode,
|
||||||
@@ -17,10 +27,30 @@ export const nodeTypes = {
|
|||||||
camera: CameraNode,
|
camera: CameraNode,
|
||||||
printer: PrinterNode,
|
printer: PrinterNode,
|
||||||
computer: ComputerNode,
|
computer: ComputerNode,
|
||||||
|
laptop: LaptopNode,
|
||||||
|
mobile: MobileNode,
|
||||||
cpl: CplNode,
|
cpl: CplNode,
|
||||||
docker_host: DockerHostNode,
|
docker_host: DockerHostNode,
|
||||||
docker_container: DockerContainerNode,
|
docker_container: DockerContainerNode,
|
||||||
generic: GenericNode,
|
generic: GenericNode,
|
||||||
groupRect: GroupRectNode,
|
groupRect: GroupRectNode,
|
||||||
group: GroupNode,
|
group: GroupNode,
|
||||||
|
text: TextNode,
|
||||||
|
zigbee_coordinator: ZigbeeCoordinatorNode,
|
||||||
|
zigbee_router: ZigbeeRouterNode,
|
||||||
|
zigbee_enddevice: ZigbeeEndDeviceNode,
|
||||||
|
grid: GridNode,
|
||||||
|
ups: UpsNode,
|
||||||
|
battery: BatteryNode,
|
||||||
|
generator: GeneratorNode,
|
||||||
|
solar_panel: SolarPanelNode,
|
||||||
|
inverter: InverterNode,
|
||||||
|
circuit_breaker: CircuitBreakerNode,
|
||||||
|
contactor: ContactorNode,
|
||||||
|
electrical_switch: ElectricalSwitchNode,
|
||||||
|
socket: SocketNode,
|
||||||
|
light: LightNode,
|
||||||
|
meter: MeterNode,
|
||||||
|
transformer: TransformerNode,
|
||||||
|
load: LoadNode,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
|
||||||
|
import dashboardIcons from '@/data/dashboardIcons.json'
|
||||||
|
|
||||||
|
const SLUGS: string[] = dashboardIcons as string[]
|
||||||
|
const PAGE = 120
|
||||||
|
|
||||||
|
interface BrandIconPickerProps {
|
||||||
|
value?: string
|
||||||
|
onSelect: (key: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandIconPicker({ value, onSelect }: BrandIconPickerProps) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [limit, setLimit] = useState(PAGE)
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
if (!q) return SLUGS
|
||||||
|
return SLUGS.filter((s) => s.includes(q))
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
const visible = filtered.slice(0, limit)
|
||||||
|
const selectedSlug = value?.startsWith(BRAND_ICON_PREFIX) ? value.slice(BRAND_ICON_PREFIX.length) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => { setQuery(e.target.value); setLimit(PAGE) }}
|
||||||
|
placeholder={`Search ${SLUGS.length} brand icons...`}
|
||||||
|
className="bg-[#0d1117] border-[#30363d] text-xs h-7"
|
||||||
|
aria-label="Brand icon search"
|
||||||
|
/>
|
||||||
|
<div className="text-[10px] text-muted-foreground/60">
|
||||||
|
{filtered.length} match{filtered.length === 1 ? '' : 'es'} · icons served via jsDelivr CDN
|
||||||
|
</div>
|
||||||
|
<div className="max-h-52 overflow-y-auto pr-1">
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{visible.map((slug) => {
|
||||||
|
const selected = slug === selectedSlug
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={slug}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(`${BRAND_ICON_PREFIX}${slug}`)}
|
||||||
|
title={slug}
|
||||||
|
aria-label={slug}
|
||||||
|
aria-pressed={selected}
|
||||||
|
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||||
|
selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/10'
|
||||||
|
: 'border-[#30363d] hover:border-[#484f58] bg-[#0d1117]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={brandIconUrl(slug)}
|
||||||
|
alt={slug}
|
||||||
|
loading="lazy"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ width: 20, height: 20, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{filtered.length > limit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLimit((l) => l + PAGE)}
|
||||||
|
className="mt-2 w-full text-[11px] text-muted-foreground hover:text-foreground py-1"
|
||||||
|
>
|
||||||
|
Load more ({filtered.length - limit} remaining)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.length === 0 && (
|
||||||
|
<div className="text-center text-[11px] text-muted-foreground py-4">No icons match.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
Globe, Router, Network, Server, Layers, Box, Container, HardDrive,
|
||||||
|
Cpu, Wifi, Camera, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Circle, Flame,
|
||||||
|
Radio, Zap, Lightbulb,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
|
import type {
|
||||||
|
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
||||||
|
} from '@/types'
|
||||||
|
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||||
|
|
||||||
|
// ── Node types exposed for custom style (skip groupRect/group) ───────────────
|
||||||
|
|
||||||
|
const EDITABLE_NODE_TYPES: NodeType[] = [
|
||||||
|
'isp', 'router', 'firewall', 'switch', 'server', 'proxmox', 'vm', 'lxc', 'nas',
|
||||||
|
'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', '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, laptop: Laptop, mobile: Smartphone, cpl: PlugZap,
|
||||||
|
docker_host: Anchor, docker_container: Package,
|
||||||
|
zigbee_coordinator: Radio, zigbee_router: Zap, zigbee_enddevice: Lightbulb,
|
||||||
|
generic: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Default style for a node type (from default theme) ─────────────────────
|
||||||
|
|
||||||
|
function defaultNodeStyle(nodeType: NodeType): NodeTypeStyle {
|
||||||
|
const accent = THEMES.default.colors.nodeAccents[nodeType] ?? THEMES.default.colors.nodeAccents.generic
|
||||||
|
return {
|
||||||
|
borderColor: accent.border,
|
||||||
|
borderOpacity: 1,
|
||||||
|
bgColor: THEMES.default.colors.nodeCardBackground,
|
||||||
|
bgOpacity: 1,
|
||||||
|
iconColor: accent.icon,
|
||||||
|
iconOpacity: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
||||||
|
return {
|
||||||
|
color: THEMES.default.colors.edgeColors[edgeType],
|
||||||
|
opacity: 1,
|
||||||
|
pathStyle: 'bezier',
|
||||||
|
animated: 'none',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Color + opacity row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface ColorRowProps {
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
opacity: number
|
||||||
|
onColorChange: (v: string) => void
|
||||||
|
onOpacityChange: (v: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorRow({ label, color, opacity, onColorChange, onOpacityChange }: ColorRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs text-[#8b949e] w-20 shrink-0">{label}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => onColorChange(e.target.value)}
|
||||||
|
className="w-7 h-7 rounded cursor-pointer border border-[#30363d] bg-transparent p-0.5"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={opacity}
|
||||||
|
onChange={(e) => onOpacityChange(parseFloat(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-[#8b949e] w-8 text-right">
|
||||||
|
{Math.round(opacity * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded border border-[#30363d] shrink-0"
|
||||||
|
style={{ background: applyOpacity(color, opacity) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Node type editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface NodeEditorProps {
|
||||||
|
nodeType: NodeType
|
||||||
|
style: NodeTypeStyle
|
||||||
|
onChange: (s: NodeTypeStyle) => void
|
||||||
|
onApplyToExisting: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditorProps) {
|
||||||
|
const set = useCallback(<K extends keyof NodeTypeStyle>(k: K, v: NodeTypeStyle[K]) => {
|
||||||
|
onChange({ ...style, [k]: v })
|
||||||
|
}, [style, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="text-sm font-semibold text-[#e6edf3]">{NODE_TYPE_LABELS[nodeType]}</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ColorRow
|
||||||
|
label="Border"
|
||||||
|
color={style.borderColor}
|
||||||
|
opacity={style.borderOpacity}
|
||||||
|
onColorChange={(v) => set('borderColor', v)}
|
||||||
|
onOpacityChange={(v) => set('borderOpacity', v)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Background"
|
||||||
|
color={style.bgColor}
|
||||||
|
opacity={style.bgOpacity}
|
||||||
|
onColorChange={(v) => set('bgColor', v)}
|
||||||
|
onOpacityChange={(v) => set('bgOpacity', v)}
|
||||||
|
/>
|
||||||
|
<ColorRow
|
||||||
|
label="Icon"
|
||||||
|
color={style.iconColor}
|
||||||
|
opacity={style.iconOpacity}
|
||||||
|
onColorChange={(v) => set('iconColor', v)}
|
||||||
|
onOpacityChange={(v) => set('iconOpacity', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3">
|
||||||
|
<div className="text-xs text-[#8b949e] mb-1">Default size</div>
|
||||||
|
<div className="text-xs text-[#8b949e]/60 mb-2">0 = auto (min 140 × 50 px, grows with content)</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">W</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={style.width}
|
||||||
|
onChange={(e) => set('width', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">H</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={10}
|
||||||
|
value={style.height}
|
||||||
|
onChange={(e) => set('height', parseInt(e.target.value) || 0)}
|
||||||
|
className="w-20 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onApplyToExisting}
|
||||||
|
>
|
||||||
|
Apply to existing {NODE_TYPE_LABELS[nodeType]} nodes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge type editor ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface EdgeEditorProps {
|
||||||
|
edgeType: EdgeType
|
||||||
|
style: EdgeTypeStyle
|
||||||
|
onChange: (s: EdgeTypeStyle) => void
|
||||||
|
onApplyToExisting: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditorProps) {
|
||||||
|
const set = useCallback(<K extends keyof EdgeTypeStyle>(k: K, v: EdgeTypeStyle[K]) => {
|
||||||
|
onChange({ ...style, [k]: v })
|
||||||
|
}, [style, onChange])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="text-sm font-semibold text-[#e6edf3]">{EDGE_TYPE_LABELS[edgeType]}</div>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<ColorRow
|
||||||
|
label="Color"
|
||||||
|
color={style.color}
|
||||||
|
opacity={style.opacity}
|
||||||
|
onColorChange={(v) => set('color', v)}
|
||||||
|
onOpacityChange={(v) => set('opacity', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['bezier', 'smooth'] as EdgePathStyle[]).map((ps) => (
|
||||||
|
<button
|
||||||
|
key={ps}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('pathStyle', ps)}
|
||||||
|
className="px-3 py-1 text-xs rounded border transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: style.pathStyle === ps ? '#00d4ff' : '#30363d',
|
||||||
|
background: style.pathStyle === ps ? '#00d4ff22' : 'transparent',
|
||||||
|
color: style.pathStyle === ps ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ps.charAt(0).toUpperCase() + ps.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[#8b949e] mb-2">Animation</div>
|
||||||
|
<select
|
||||||
|
value={style.animated}
|
||||||
|
onChange={(e) => set('animated', e.target.value as EdgeTypeStyle['animated'])}
|
||||||
|
className="w-full h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
>
|
||||||
|
<option value="none">None</option>
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
<option value="flow">Flow</option>
|
||||||
|
<option value="snake">Snake</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onApplyToExisting}
|
||||||
|
>
|
||||||
|
Apply to existing {EDGE_TYPE_LABELS[edgeType]} edges
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main modal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Tab = 'nodes' | 'edges'
|
||||||
|
type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeType } | null
|
||||||
|
|
||||||
|
interface CustomStyleModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
||||||
|
const { customStyle, setCustomStyle } = useThemeStore()
|
||||||
|
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<Tab>('nodes')
|
||||||
|
const [selection, setSelection] = useState<Selection>(null)
|
||||||
|
const [draft, setDraft] = useState<CustomStyleDef>(() => ({
|
||||||
|
nodes: { ...customStyle.nodes },
|
||||||
|
edges: { ...customStyle.edges },
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleOpen = (isOpen: boolean) => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Reset draft to current saved customStyle on open
|
||||||
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
|
setSelection(null)
|
||||||
|
} else {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getNodeStyle = (t: NodeType): NodeTypeStyle =>
|
||||||
|
draft.nodes[t] ?? defaultNodeStyle(t)
|
||||||
|
|
||||||
|
const getEdgeStyle = (t: EdgeType): EdgeTypeStyle =>
|
||||||
|
draft.edges[t] ?? defaultEdgeStyle(t)
|
||||||
|
|
||||||
|
const handleNodeChange = (t: NodeType, s: NodeTypeStyle) =>
|
||||||
|
setDraft((d) => ({ ...d, nodes: { ...d.nodes, [t]: s } }))
|
||||||
|
|
||||||
|
const handleEdgeChange = (t: EdgeType, s: EdgeTypeStyle) =>
|
||||||
|
setDraft((d) => ({ ...d, edges: { ...d.edges, [t]: s } }))
|
||||||
|
|
||||||
|
const handleApplyNodeType = (t: NodeType) => {
|
||||||
|
const style = getNodeStyle(t)
|
||||||
|
applyTypeNodeStyle(t, style)
|
||||||
|
toast.success(`Applied style to all ${NODE_TYPE_LABELS[t]} nodes`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyEdgeType = (t: EdgeType) => {
|
||||||
|
const style = getEdgeStyle(t)
|
||||||
|
applyTypeEdgeStyle(t, style)
|
||||||
|
toast.success(`Applied style to all ${EDGE_TYPE_LABELS[t]} edges`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setCustomStyle(draft)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success('Custom style saved — save your canvas to persist')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApplyAll = () => {
|
||||||
|
setCustomStyle(draft)
|
||||||
|
applyAllCustomStyles(draft)
|
||||||
|
markUnsaved()
|
||||||
|
toast.success('Custom style applied to all nodes and edges')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedNodeStyle = selection?.kind === 'node' ? getNodeStyle(selection.type) : null
|
||||||
|
const selectedEdgeStyle = selection?.kind === 'edge' ? getEdgeStyle(selection.type) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpen}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] flex flex-col p-0 gap-0">
|
||||||
|
<DialogHeader className="px-5 pt-5 pb-3 border-b border-[#30363d]">
|
||||||
|
<DialogTitle className="text-sm font-semibold">Custom Style Editor</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden min-h-0">
|
||||||
|
{/* Left panel — type list */}
|
||||||
|
<div className="w-52 shrink-0 border-r border-[#30363d] flex flex-col overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex border-b border-[#30363d]">
|
||||||
|
{(['nodes', 'edges'] as Tab[]).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setTab(t); setSelection(null) }}
|
||||||
|
className="flex-1 py-2 text-xs font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
borderBottom: tab === t ? '2px solid #00d4ff' : '2px solid transparent',
|
||||||
|
color: tab === t ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type list */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-1">
|
||||||
|
{tab === 'nodes' && EDITABLE_NODE_TYPES.map((t) => {
|
||||||
|
const Icon = NODE_ICONS[t] ?? Circle
|
||||||
|
const style = draft.nodes[t]
|
||||||
|
const isSelected = selection?.kind === 'node' && selection.type === t
|
||||||
|
const swatchColor = style
|
||||||
|
? applyOpacity(style.borderColor, style.borderOpacity)
|
||||||
|
: THEMES.default.colors.nodeAccents[t]?.border ?? '#8b949e'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelection({ kind: 'node', type: t })}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
<span className="flex-1 truncate">{NODE_TYPE_LABELS[t]}</span>
|
||||||
|
<span
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ background: swatchColor }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{tab === 'edges' && EDITABLE_EDGE_TYPES.map((t) => {
|
||||||
|
const style = draft.edges[t]
|
||||||
|
const isSelected = selection?.kind === 'edge' && selection.type === t
|
||||||
|
const swatchColor = style
|
||||||
|
? applyOpacity(style.color, style.opacity)
|
||||||
|
: THEMES.default.colors.edgeColors[t]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelection({ kind: 'edge', type: t })}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs transition-colors text-left"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#21262d' : 'transparent',
|
||||||
|
color: isSelected ? '#e6edf3' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
|
||||||
|
<span
|
||||||
|
className="w-8 h-1.5 rounded-full shrink-0"
|
||||||
|
style={{ background: swatchColor }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right panel — editor */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5">
|
||||||
|
{!selection && (
|
||||||
|
<div className="flex items-center justify-center h-full text-xs text-[#8b949e]">
|
||||||
|
Select a {tab === 'nodes' ? 'node type' : 'edge type'} from the list to edit its style
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection?.kind === 'node' && selectedNodeStyle && (
|
||||||
|
<NodeEditor
|
||||||
|
key={selection.type}
|
||||||
|
nodeType={selection.type}
|
||||||
|
style={selectedNodeStyle}
|
||||||
|
onChange={(s) => handleNodeChange(selection.type, s)}
|
||||||
|
onApplyToExisting={() => handleApplyNodeType(selection.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selection?.kind === 'edge' && selectedEdgeStyle && (
|
||||||
|
<EdgeEditor
|
||||||
|
key={selection.type}
|
||||||
|
edgeType={selection.type}
|
||||||
|
style={selectedEdgeStyle}
|
||||||
|
onChange={(s) => handleEdgeChange(selection.type, s)}
|
||||||
|
onApplyToExisting={() => handleApplyEdgeType(selection.type)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between gap-2 px-5 py-3 border-t border-[#30363d]">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-[#30363d] text-[#e6edf3] hover:bg-[#21262d]"
|
||||||
|
onClick={handleSave}
|
||||||
|
>
|
||||||
|
Save Custom Style
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={handleApplyAll}
|
||||||
|
>
|
||||||
|
Apply All to Canvas
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw } from 'lucide-react'
|
import { RotateCcw } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -68,8 +69,8 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
<Label className="text-xs text-muted-foreground">Link Type</Label>
|
||||||
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
<Select value={type} onValueChange={(v) => setType(v as EdgeType)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Edge type selector">
|
||||||
<SelectValue />
|
<SelectValue>{EDGE_TYPE_LABELS[type]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{EDGE_TYPES.map(([value, label]) => (
|
{EDGE_TYPES.map(([value, label]) => (
|
||||||
@@ -89,7 +90,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={vlanId}
|
value={vlanId}
|
||||||
onChange={(e) => setVlanId(e.target.value)}
|
onChange={(e) => setVlanId(e.target.value)}
|
||||||
placeholder="e.g. 20"
|
placeholder="e.g. 20"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -100,19 +101,21 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
placeholder="e.g. 1G, trunk..."
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
<Label className="text-xs text-muted-foreground">Path Style</Label>
|
||||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||||
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
{(['bezier', 'smooth'] as EdgePathStyle[]).map((style) => (
|
||||||
<button
|
<button
|
||||||
key={style}
|
key={style}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPathStyle(style)}
|
onClick={() => setPathStyle(style)}
|
||||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Path style ${style}`}
|
||||||
style={{
|
style={{
|
||||||
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
background: pathStyle === style ? '#00d4ff22' : '#21262d',
|
||||||
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
color: pathStyle === style ? '#00d4ff' : '#8b949e',
|
||||||
@@ -127,13 +130,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||||
<div className="flex rounded-md overflow-hidden border border-[#30363d]">
|
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||||
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
{(['none', 'basic', 'snake', 'flow'] as AnimMode[]).map((mode, i) => (
|
||||||
<button
|
<button
|
||||||
key={mode}
|
key={mode}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAnimation(mode)}
|
onClick={() => setAnimation(mode)}
|
||||||
className="flex-1 py-1 text-xs capitalize transition-colors"
|
className="flex-1 py-1 text-xs capitalize transition-colors cursor-pointer"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Animation mode ${mode}`}
|
||||||
style={{
|
style={{
|
||||||
background: animation === mode ? '#00d4ff22' : '#21262d',
|
background: animation === mode ? '#00d4ff22' : '#21262d',
|
||||||
color: animation === mode ? '#00d4ff' : '#8b949e',
|
color: animation === mode ? '#00d4ff' : '#8b949e',
|
||||||
@@ -160,8 +165,10 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label
|
<label
|
||||||
className="relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer"
|
className={`relative flex items-center gap-2.5 px-2.5 h-8 rounded-md border cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
style={{ borderColor: customColor ? effectiveColor : '#30363d', background: '#21262d' }}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="Edge color picker"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -189,13 +196,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10" onClick={handleDelete}>
|
<Button type="button" variant="ghost" size="sm" className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer" onClick={handleDelete}>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="ghost" size="sm" className="cursor-pointer" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{onDelete ? 'Save' : 'Connect'}
|
{onDelete ? 'Save' : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/* Slider container: strip native chrome so custom track/thumb align cleanly */
|
||||||
|
.slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
height: 13px; /* match thumb height so vertical centering is the input's box center */
|
||||||
|
}
|
||||||
|
.slider-thumb:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Track */
|
||||||
|
.slider-accent::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #00d4ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.slider-accent::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #00d4ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thumb — must offset on webkit so it centers on the 4px track */
|
||||||
|
.slider-thumb::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
border: 2px solid #21262d;
|
||||||
|
margin-top: -4.5px; /* (13 - 4) / 2 */
|
||||||
|
}
|
||||||
|
.slider-thumb::-moz-range-thumb {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #00d4ff;
|
||||||
|
border: 2px solid #21262d;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -6,6 +7,7 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import type { TextPosition } from '@/types'
|
import type { TextPosition } from '@/types'
|
||||||
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
import styles from './GroupRectModal.module.css'
|
||||||
|
|
||||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
@@ -87,6 +89,8 @@ const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
|
|||||||
{ value: 'bottom-right', label: '↘' },
|
{ value: 'bottom-right', label: '↘' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const getFontLabel = (value: string) => FONTS.find((f) => f.value === value)?.label ?? value
|
||||||
|
|
||||||
interface GroupRectModalProps {
|
interface GroupRectModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@@ -129,7 +133,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
value={form.label}
|
value={form.label}
|
||||||
onChange={(e) => set('label', e.target.value)}
|
onChange={(e) => set('label', e.target.value)}
|
||||||
placeholder="Zone name…"
|
placeholder="Zone name…"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,8 +141,10 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||||
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Font selector">
|
||||||
<SelectValue />
|
<SelectValue>
|
||||||
|
{getFontLabel(form.font)}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
{FONTS.map((f) => (
|
{FONTS.map((f) => (
|
||||||
@@ -162,7 +168,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={value}
|
title={value}
|
||||||
onClick={() => set('text_position', value)}
|
onClick={() => set('text_position', value)}
|
||||||
className="h-8 rounded text-base transition-colors"
|
className={`h-8 rounded text-base transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Text position ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -187,7 +194,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('label_position', value)}
|
onClick={() => set('label_position', value)}
|
||||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Label position ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -227,7 +235,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
max={100}
|
max={100}
|
||||||
value={alpha}
|
value={alpha}
|
||||||
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
className={`w-full cursor-pointer mt-2 ${styles['slider-thumb']} ${styles['slider-accent']}`}
|
||||||
title={`Opacity: ${alpha}%`}
|
title={`Opacity: ${alpha}%`}
|
||||||
/>
|
/>
|
||||||
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
@@ -248,7 +256,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('text_size', value)}
|
onClick={() => set('text_size', value)}
|
||||||
className="flex items-center justify-center h-8 rounded transition-colors"
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Text size ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -275,7 +284,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => set('border_style', value)}
|
onClick={() => set('border_style', value)}
|
||||||
className="flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors"
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Border style ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -301,7 +311,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('border_width', value)}
|
onClick={() => set('border_width', value)}
|
||||||
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Border width ${label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
@@ -319,7 +330,7 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
|
||||||
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
<Select value={String(form.z_order)} onValueChange={(v: string | null) => set('z_order', v !== null ? Number(v) : 1)}>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Z-order selector">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -338,17 +349,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
onClick={() => { onDelete(); onClose() }}
|
onClick={() => { onDelete(); onClose() }}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2 ml-auto">
|
<div className="flex gap-2 ml-auto">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
{title === 'Add Zone' ? 'Add' : 'Save'}
|
{title === 'Add Zone' ? 'Add' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
import { RotateCcw, ChevronDown } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -7,17 +8,24 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
|
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[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
{ label: 'Hardware', types: ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
|
||||||
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
|
||||||
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
|
||||||
{ label: 'Generic', types: ['computer', 'generic', 'groupRect'] },
|
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
|
||||||
|
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
|
||||||
|
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
|
||||||
|
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
|
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||||
|
|
||||||
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
|
||||||
none: 'None',
|
none: 'None',
|
||||||
@@ -43,22 +51,39 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParentCandidate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: NodeType
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
interface NodeModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSubmit: (data: Partial<NodeData>) => void
|
onSubmit: (data: Partial<NodeData>) => void
|
||||||
initial?: Partial<NodeData>
|
initial?: Partial<NodeData>
|
||||||
title?: string
|
title?: string
|
||||||
parentContainerNodes?: { id: string; label: string; nodeType?: NodeType }[]
|
parentCandidates?: ParentCandidate[]
|
||||||
|
currentNodeId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentContainerNodes = [] }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
||||||
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
|
if (ZIGBEE_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
const [iconSearch, setIconSearch] = useState('')
|
const [iconSearch, setIconSearch] = useState('')
|
||||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||||
|
const [iconTab, setIconTab] = useState<'generic' | 'brand'>(isBrandIconKey(initial?.custom_icon) ? 'brand' : 'generic')
|
||||||
const [labelError, setLabelError] = useState(false)
|
const [labelError, setLabelError] = useState(false)
|
||||||
|
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||||
|
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||||
|
const hasAppearanceOverrides = Boolean(
|
||||||
|
form.custom_colors?.border
|
||||||
|
|| form.custom_colors?.background
|
||||||
|
|| form.custom_colors?.icon
|
||||||
|
)
|
||||||
|
|
||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
@@ -72,20 +97,25 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
setLabelError(false)
|
setLabelError(false)
|
||||||
const selectedType = (form.type ?? 'generic') as NodeType
|
const selectedType = (form.type ?? 'generic') as NodeType
|
||||||
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
|
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({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredParentNodes = form.type === 'docker_container'
|
|
||||||
? parentContainerNodes.filter((n) => n.nodeType === 'docker_host')
|
|
||||||
: parentContainerNodes
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -95,8 +125,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Type + Icon on the same row */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
|
<Select value={form.type} onValueChange={(v) => {
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8 w-full">
|
const t = v as NodeType
|
||||||
|
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>
|
<SelectValue>{NODE_TYPE_LABELS[(form.type ?? 'server') as NodeType]}</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
@@ -137,10 +175,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIconPickerOpen((o) => !o)}
|
onClick={() => setIconPickerOpen((o) => !o)}
|
||||||
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors w-full"
|
className={`flex items-center justify-between gap-2 h-8 px-3 bg-[#21262d] border border-[#30363d] text-sm transition-colors w-full cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}
|
||||||
|
aria-label="Icon picker trigger"
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 min-w-0">
|
<span className="flex items-center gap-2 min-w-0">
|
||||||
{(() => {
|
{(() => {
|
||||||
|
if (isBrandIconKey(form.custom_icon)) {
|
||||||
|
const slug = brandIconSlug(form.custom_icon!)
|
||||||
|
return <><img src={brandIconUrl(slug)} alt={slug} width={13} height={13} className="shrink-0" style={{ width: 13, height: 13, objectFit: 'contain' }} /><span className="text-foreground truncate">{slug}</span></>
|
||||||
|
}
|
||||||
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff] shrink-0' })}<span className="text-foreground truncate">{entry.label}</span></>
|
||||||
@@ -156,11 +199,42 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d] col-span-2">
|
||||||
|
<div className="flex gap-1 mb-1" role="tablist" aria-label="Icon source">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={iconTab === 'generic'}
|
||||||
|
onClick={() => setIconTab('generic')}
|
||||||
|
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
iconTab === 'generic' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Generic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={iconTab === 'brand'}
|
||||||
|
onClick={() => setIconTab('brand')}
|
||||||
|
className={`text-[11px] px-2 py-1 rounded transition-colors cursor-pointer ${
|
||||||
|
iconTab === 'brand' ? 'bg-[#21262d] text-foreground border border-[#30363d]' : 'text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Brand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{iconTab === 'brand' ? (
|
||||||
|
<BrandIconPicker
|
||||||
|
value={form.custom_icon}
|
||||||
|
onSelect={(key) => { set('custom_icon', key); setIconPickerOpen(false) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Input
|
<Input
|
||||||
value={iconSearch}
|
value={iconSearch}
|
||||||
onChange={(e) => setIconSearch(e.target.value)}
|
onChange={(e) => setIconSearch(e.target.value)}
|
||||||
placeholder="Search icons…"
|
placeholder="Search icons…"
|
||||||
className="bg-[#21262d] border-[#30363d] text-xs h-7"
|
className={`bg-[#21262d] border-[#30363d] text-xs h-7 ${modalStyles['modal-radius']}`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
|
||||||
@@ -182,7 +256,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
type="button"
|
type="button"
|
||||||
title={entry.label}
|
title={entry.label}
|
||||||
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
|
||||||
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
|
className={`flex items-center justify-center w-7 h-7 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Select icon ${entry.label}`}
|
||||||
style={{
|
style={{
|
||||||
background: isSelected ? '#00d4ff22' : 'transparent',
|
background: isSelected ? '#00d4ff22' : 'transparent',
|
||||||
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
|
||||||
@@ -200,6 +275,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -210,7 +287,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.label ?? ''}
|
value={form.label ?? ''}
|
||||||
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
|
||||||
placeholder="My Server"
|
placeholder="My Server"
|
||||||
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
|
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'} ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,90 +299,137 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.hostname ?? ''}
|
value={form.hostname ?? ''}
|
||||||
onChange={(e) => set('hostname', e.target.value)}
|
onChange={(e) => set('hostname', e.target.value)}
|
||||||
placeholder="server.lan"
|
placeholder="server.lan"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IP */}
|
{/* IP */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">IP Address <span className="text-muted-foreground/50">(comma-separated)</span></Label>
|
<Label className="text-xs text-muted-foreground">IP Address</Label>
|
||||||
<Input
|
<Input
|
||||||
value={form.ip ?? ''}
|
value={form.ip ?? ''}
|
||||||
onChange={(e) => set('ip', e.target.value)}
|
onChange={(e) => set('ip', e.target.value)}
|
||||||
placeholder="192.168.1.x, 2001:db8::1"
|
placeholder="192.168.1.x, 2001:db8::1"
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check method */}
|
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
||||||
<div className="flex flex-col gap-1.5">
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
<div className="flex flex-col gap-1.5">
|
||||||
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
<Label className="text-xs text-muted-foreground">Check Method</Label>
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
|
||||||
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`} aria-label="Check method selector">
|
||||||
</SelectTrigger>
|
<SelectValue>{CHECK_METHOD_LABELS[(form.check_method ?? 'ping') as CheckMethod]}</SelectValue>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
|
||||||
{CHECK_METHODS.map((m) => (
|
|
||||||
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Check target */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
|
||||||
<Input
|
|
||||||
value={form.check_target ?? ''}
|
|
||||||
onChange={(e) => set('check_target', e.target.value)}
|
|
||||||
placeholder="http://..."
|
|
||||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Parent container */}
|
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && filteredParentNodes.length > 0 && (
|
|
||||||
<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">
|
|
||||||
<SelectValue placeholder="None (standalone)">
|
|
||||||
{form.parent_id
|
|
||||||
? (filteredParentNodes.find((n) => n.id === form.parent_id)?.label ?? 'None (standalone)')
|
|
||||||
: 'None (standalone)'}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
|
{CHECK_METHODS.map((m) => (
|
||||||
{filteredParentNodes.map((n) => (
|
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
|
||||||
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Check target — hidden for zigbee nodes */}
|
||||||
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Check Target</Label>
|
||||||
|
<Input
|
||||||
|
value={form.check_target ?? ''}
|
||||||
|
onChange={(e) => set('check_target', e.target.value)}
|
||||||
|
placeholder="http://..."
|
||||||
|
className={`bg-[#21262d] border-[#30363d] font-mono text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</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 */}
|
||||||
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
{CONTAINER_MODE_TYPES.includes((form.type ?? 'generic') as NodeType) && (
|
||||||
<div className="flex items-center justify-between col-span-2 py-1">
|
<div className="flex items-center justify-between col-span-2 py-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
<Label className="text-xs text-muted-foreground">Container Mode</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Allow other nodes to nest inside this node</span>
|
<span className="text-[10px] text-muted-foreground/60">
|
||||||
|
Allow other nodes to nest inside this node
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-label="Container Mode"
|
||||||
|
aria-checked={!!form.container_mode}
|
||||||
|
onClick={() => set('container_mode', !form.container_mode)}
|
||||||
|
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
|
style={{
|
||||||
|
transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Service visibility */}
|
||||||
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
|
<div className="flex items-start justify-between col-span-2 py-1">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Show Services</Label>
|
||||||
|
<span className="text-[10px] text-muted-foreground/60">Display discovered services on the node card</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={!!form.container_mode}
|
aria-label="Show Services"
|
||||||
onClick={() => set('container_mode', !form.container_mode)}
|
aria-checked={showServicesEnabled}
|
||||||
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
onClick={() => set('custom_colors', {
|
||||||
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
|
...form.custom_colors,
|
||||||
|
show_services: !showServicesEnabled,
|
||||||
|
})}
|
||||||
|
className="relative inline-flex h-5 w-9 mt-1 shrink-0 cursor-pointer rounded-full transition-colors focus:outline-none"
|
||||||
|
style={{ background: showServicesEnabled ? resolvedNodeColors.icon : '#30363d' }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none absolute top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-all"
|
className="pointer-events-none absolute top-px left-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-in-out"
|
||||||
style={{ left: form.container_mode ? 'calc(100% - 18px)' : '2px' }}
|
style={{ transform: showServicesEnabled ? 'translateX(16px)' : 'translateX(0)' }}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,10 +439,20 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<div className="flex flex-col gap-2 col-span-2">
|
<div className="flex flex-col gap-2 col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
<Label className="text-xs text-muted-foreground">Appearance</Label>
|
||||||
{form.custom_colors && (
|
{hasAppearanceOverrides && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => set('custom_colors', undefined)}
|
onClick={() => setForm((f) => {
|
||||||
|
if (!f.custom_colors) return f
|
||||||
|
const { border, background, icon, ...rest } = f.custom_colors
|
||||||
|
void border
|
||||||
|
void background
|
||||||
|
void icon
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
custom_colors: Object.keys(rest).length > 0 ? rest : undefined,
|
||||||
|
}
|
||||||
|
})}
|
||||||
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<RotateCcw size={10} /> Reset to defaults
|
<RotateCcw size={10} /> Reset to defaults
|
||||||
@@ -333,9 +467,11 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
return (
|
return (
|
||||||
<div key={key} className="flex flex-col gap-1 items-center">
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
<label
|
<label
|
||||||
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all"
|
className={`relative w-full h-7 rounded-md border cursor-pointer overflow-hidden transition-all ${modalStyles['modal-interactive']}`}
|
||||||
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
|
||||||
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Color picker for ${key}`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
@@ -350,29 +486,55 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{!form.custom_colors && (
|
<div className="min-h-3.5">
|
||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
{!hasAppearanceOverrides && (
|
||||||
)}
|
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Bottom connection points (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-1.5 col-span-2">
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Select
|
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
||||||
value={String(form.bottom_handles ?? 1)}
|
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
||||||
onValueChange={(v) => set('bottom_handles', parseInt(v ?? '1', 10))}
|
</div>
|
||||||
>
|
<input
|
||||||
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
|
type="range"
|
||||||
<SelectValue />
|
min={MIN_BOTTOM_HANDLES}
|
||||||
</SelectTrigger>
|
max={MAX_BOTTOM_HANDLES}
|
||||||
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
step={1}
|
||||||
<SelectItem value="1" className="text-sm">1 - center</SelectItem>
|
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
||||||
<SelectItem value="2" className="text-sm">2 - left / right</SelectItem>
|
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
||||||
<SelectItem value="3" className="text-sm">3 - left / center / right</SelectItem>
|
aria-label="Bottom connection points slider"
|
||||||
<SelectItem value="4" className="text-sm">4 - evenly spaced</SelectItem>
|
className="w-full accent-[#00d4ff] cursor-pointer"
|
||||||
</SelectContent>
|
/>
|
||||||
</Select>
|
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
||||||
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -383,22 +545,42 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
value={form.notes ?? ''}
|
value={form.notes ?? ''}
|
||||||
onChange={(e) => set('notes', e.target.value)}
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
placeholder="Optional notes"
|
placeholder="Optional notes"
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
{/* Show delete button only for edit mode (not add) */}
|
||||||
Cancel
|
{title !== 'Add Node' ? (
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="submit"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
>
|
onClick={() => {
|
||||||
{title === 'Add Node' ? 'Add' : 'Save'}
|
if (window.confirm('Delete this node?')) {
|
||||||
</Button>
|
onSubmit({ ...form, _delete: true })
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ minWidth: 64 }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
) : <span />}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer"
|
||||||
|
>
|
||||||
|
{title === 'Add Node' ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ interface Service {
|
|||||||
|
|
||||||
export interface PendingDevice {
|
export interface PendingDevice {
|
||||||
id: string
|
id: string
|
||||||
ip: string
|
ip: string | null
|
||||||
mac: string | null
|
mac: string | null
|
||||||
hostname: string | null
|
hostname: string | null
|
||||||
os: string | null
|
os: string | null
|
||||||
@@ -20,6 +20,12 @@ export interface PendingDevice {
|
|||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
discovery_source: string | null
|
discovery_source: string | null
|
||||||
|
ieee_address?: string | null
|
||||||
|
friendly_name?: string | null
|
||||||
|
device_subtype?: string | null
|
||||||
|
model?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
lqi?: number | null
|
||||||
discovered_at: string
|
discovered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +83,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
if (!device) return null
|
if (!device) return null
|
||||||
|
|
||||||
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
const isZigbee = device.discovery_source === 'zigbee'
|
||||||
|
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||||
|
|
||||||
const handleApprove = () => { onApprove(device) }
|
const handleApprove = () => { onApprove(device) }
|
||||||
const handleHide = () => { onHide(device); onClose() }
|
const handleHide = () => { onHide(device); onClose() }
|
||||||
@@ -88,17 +96,30 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
|
||||||
{device.hostname ?? device.ip}
|
{titleLabel}
|
||||||
|
{isZigbee && (
|
||||||
|
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
|
||||||
|
Zigbee
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 mt-1">
|
<div className="flex flex-col gap-4 mt-1">
|
||||||
{/* Device info */}
|
{/* Device info */}
|
||||||
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
|
||||||
<InfoRow label="IP" value={device.ip} />
|
{device.ip && <InfoRow label="IP" value={device.ip} />}
|
||||||
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
|
||||||
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
{device.mac && <InfoRow label="MAC" value={device.mac} />}
|
||||||
{device.os && <InfoRow label="OS" value={device.os} />}
|
{device.os && <InfoRow label="OS" value={device.os} />}
|
||||||
|
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
|
||||||
|
{device.friendly_name && device.friendly_name !== device.hostname && (
|
||||||
|
<InfoRow label="Name" value={device.friendly_name} />
|
||||||
|
)}
|
||||||
|
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
|
||||||
|
{device.model && <InfoRow label="Model" value={device.model} />}
|
||||||
|
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
|
||||||
|
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
|
||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<InfoRow label="Type" value={device.suggested_type} />
|
<InfoRow label="Type" value={device.suggested_type} />
|
||||||
)}
|
)}
|
||||||
@@ -108,8 +129,8 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
|
||||||
<div>
|
{!isZigbee && <div>
|
||||||
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
|
||||||
Services found ({device.services.length})
|
Services found ({device.services.length})
|
||||||
</p>
|
</p>
|
||||||
@@ -138,7 +159,7 @@ export function PendingDeviceModal({ device, onClose, onApprove, onHide, onIgnor
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
|
|||||||
@@ -0,0 +1,685 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
|
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 { 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'
|
||||||
|
|
||||||
|
interface PendingDevicesModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
highlightId?: string
|
||||||
|
initialStatus?: 'pending' | 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT_COLORS: Record<number, string> = {
|
||||||
|
22: '#a855f7', // SSH purple
|
||||||
|
80: '#00d4ff', // HTTP cyan
|
||||||
|
443: '#39d353', // HTTPS green
|
||||||
|
53: '#e3b341', // DNS amber
|
||||||
|
3306: '#a855f7', // MySQL
|
||||||
|
5432: '#a855f7', // Postgres
|
||||||
|
6379: '#f85149', // Redis
|
||||||
|
9090: '#e3b341', // Prometheus
|
||||||
|
3000: '#00d4ff', // Grafana/dev
|
||||||
|
8080: '#00d4ff',
|
||||||
|
8443: '#39d353',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
|
hypervisor: '#ff6e00',
|
||||||
|
nas: '#39d353',
|
||||||
|
automation: '#a855f7',
|
||||||
|
containers: '#00d4ff',
|
||||||
|
network: '#39d353',
|
||||||
|
security: '#f85149',
|
||||||
|
monitoring: '#e3b341',
|
||||||
|
database: '#a855f7',
|
||||||
|
web: '#00d4ff',
|
||||||
|
media: '#ff6e00',
|
||||||
|
iot: '#e3b341',
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceColor(port: number | null | undefined, category?: string | null): string {
|
||||||
|
if (port != null && PORT_COLORS[port]) return PORT_COLORS[port]
|
||||||
|
if (category && CATEGORY_COLORS[category.toLowerCase()]) return CATEGORY_COLORS[category.toLowerCase()]
|
||||||
|
return '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, React.ElementType> = {
|
||||||
|
isp: Globe,
|
||||||
|
router: Router,
|
||||||
|
server: Server,
|
||||||
|
proxmox: Layers,
|
||||||
|
vm: Box,
|
||||||
|
lxc: Container,
|
||||||
|
nas: HardDrive,
|
||||||
|
iot: Cpu,
|
||||||
|
ap: Wifi,
|
||||||
|
switch: Network,
|
||||||
|
generic: Circle,
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceFilter = 'all' | 'ip' | 'zigbee'
|
||||||
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
|
function inferSource(d: PendingDevice): 'zigbee' | 'ip' {
|
||||||
|
if (d.discovery_source === 'zigbee' || d.ieee_address) return 'zigbee'
|
||||||
|
return 'ip'
|
||||||
|
}
|
||||||
|
|
||||||
|
const COMMON_PORTS = new Set([22, 80, 443])
|
||||||
|
|
||||||
|
function specialServiceName(d: PendingDevice): string | undefined {
|
||||||
|
const candidates = (d.services ?? []).filter(
|
||||||
|
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
|
||||||
|
)
|
||||||
|
// Deprioritize generic web category so apps like home assistant / jellyfin win
|
||||||
|
const nonWeb = candidates.find((s) => s.category?.toLowerCase() !== 'web')
|
||||||
|
return (nonWeb ?? candidates[0])?.service_name ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceLabel(d: PendingDevice): string {
|
||||||
|
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
||||||
|
if (!edges || edges.length === 0) return
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
edges: [
|
||||||
|
...state.edges,
|
||||||
|
...edges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'iot',
|
||||||
|
data: { type: 'iot' as const },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
hasUnsavedChanges: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PendingDevicesModal({ open, onClose, highlightId, initialStatus = 'pending' }: PendingDevicesModalProps) {
|
||||||
|
const [devices, setDevices] = useState<PendingDevice[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
||||||
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>(initialStatus)
|
||||||
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = statusFilter === 'pending' ? await scanApi.pending() : await scanApi.hidden()
|
||||||
|
setDevices(res.data)
|
||||||
|
} catch {
|
||||||
|
toast.error(`Failed to load ${statusFilter} devices`)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [statusFilter])
|
||||||
|
|
||||||
|
useEffect(() => { if (open) load() }, [open, load])
|
||||||
|
useEffect(() => { if (open && scanEventTs > 0) load() }, [scanEventTs, open, load])
|
||||||
|
|
||||||
|
// Reset transient state when reopening
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setSearch('')
|
||||||
|
} else {
|
||||||
|
setStatusFilter(initialStatus)
|
||||||
|
}
|
||||||
|
}, [open, initialStatus])
|
||||||
|
|
||||||
|
const distinctTypes = useMemo(() => {
|
||||||
|
const set = new Set<string>()
|
||||||
|
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
|
||||||
|
return [...set].sort()
|
||||||
|
}, [devices])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase()
|
||||||
|
return devices.filter((d) => {
|
||||||
|
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
||||||
|
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||||
|
if (q) {
|
||||||
|
const hay = [
|
||||||
|
d.friendly_name, d.hostname, d.ip, d.mac, d.ieee_address, d.vendor, d.model,
|
||||||
|
...d.services.map((s) => s.service_name),
|
||||||
|
].filter(Boolean).join(' ').toLowerCase()
|
||||||
|
if (!hay.includes(q)) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}, [devices, search, sourceFilter, typeFilter])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightId || loading || !open) return
|
||||||
|
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||||
|
}, [highlightId, loading, open, filtered])
|
||||||
|
|
||||||
|
const toggleSelect = (id: string) => {
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCardClick = (d: PendingDevice) => {
|
||||||
|
if (selectMode) { toggleSelect(d.id); return }
|
||||||
|
if (statusFilter === 'hidden') { handleRestore(d); return }
|
||||||
|
setSelected(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.restore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
toast.success(`Restored ${deviceLabel(device)}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to restore device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkRestore = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkRestore(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Restored ${res.data.restored} device${res.data.restored !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk restore devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterSelectMode = () => {
|
||||||
|
setSelectMode(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitSelectMode = () => {
|
||||||
|
setSelectMode(false)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllVisible = () => {
|
||||||
|
setSelectedIds(new Set(filtered.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
const targets = filtered
|
||||||
|
if (targets.length === 0) return
|
||||||
|
const filtersActive = targets.length !== devices.length
|
||||||
|
try {
|
||||||
|
if (filtersActive) {
|
||||||
|
const results = await Promise.allSettled(targets.map((d) => scanApi.ignore(d.id)))
|
||||||
|
const failed = results.filter((r) => r.status === 'rejected').length
|
||||||
|
const removedIds = new Set(
|
||||||
|
targets.filter((_, i) => results[i].status === 'fulfilled').map((d) => d.id)
|
||||||
|
)
|
||||||
|
setDevices((prev) => prev.filter((d) => !removedIds.has(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
if (failed > 0) toast.error(`Removed ${removedIds.size}, ${failed} failed`)
|
||||||
|
else toast.success(`Removed ${removedIds.size} device${removedIds.size !== 1 ? 's' : ''}`)
|
||||||
|
} else {
|
||||||
|
await scanApi.clearPending()
|
||||||
|
setDevices([])
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success('Pending devices cleared')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to clear pending devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleApprove = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
const fallbackLabel = deviceLabel(device)
|
||||||
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
|
const zigbee = isZigbeeType(type)
|
||||||
|
const properties = zigbee ? buildZigbeeProperties(device) : []
|
||||||
|
const nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: zigbee ? 'online' : 'unknown',
|
||||||
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
properties,
|
||||||
|
}
|
||||||
|
const res = await scanApi.approve(device.id, nodeData)
|
||||||
|
const nodeId = res.data.node_id
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type: nodeData.type,
|
||||||
|
position: { x: 400, y: 300 },
|
||||||
|
data: { ...nodeData, status: zigbee ? ('online' as const) : ('unknown' as const) },
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to approve device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHide = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.hide(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
toast.success('Device hidden')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to hide device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIgnore = async (device: PendingDevice) => {
|
||||||
|
try {
|
||||||
|
await scanApi.ignore(device.id)
|
||||||
|
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
||||||
|
setSelected(null)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to remove device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkApprove = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkApprove(ids)
|
||||||
|
const deviceToNode: Record<string, string> = {}
|
||||||
|
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||||
|
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
||||||
|
approvedDevices.forEach((d, i) => {
|
||||||
|
const nodeId = deviceToNode[d.id]
|
||||||
|
if (!nodeId) return
|
||||||
|
const type = (d.suggested_type ?? 'generic') as NodeType
|
||||||
|
const zigbee = isZigbeeType(type)
|
||||||
|
addNode({
|
||||||
|
id: nodeId,
|
||||||
|
type,
|
||||||
|
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
||||||
|
data: {
|
||||||
|
label: deviceLabel(d),
|
||||||
|
type,
|
||||||
|
ip: d.ip ?? undefined,
|
||||||
|
hostname: d.hostname ?? undefined,
|
||||||
|
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||||
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
properties: zigbee ? buildZigbeeProperties(d) : [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
injectAutoEdges(res.data.edges)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
|
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk approve devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBulkHide = async () => {
|
||||||
|
const ids = [...selectedIds]
|
||||||
|
if (ids.length === 0) return
|
||||||
|
try {
|
||||||
|
const res = await scanApi.bulkHide(ids)
|
||||||
|
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to bulk hide devices')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts: 's' select-mode, 'a' select-all-visible, Esc clears selection or closes, '/' focuses search
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null
|
||||||
|
const inField = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT')
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (selectMode && selectedIds.size > 0) { e.preventDefault(); setSelectedIds(new Set()) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (inField) return
|
||||||
|
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() }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, selectMode, selectedIds, filtered])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[95vw] h-[90vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
{statusFilter === 'pending' ? 'Pending Devices' : 'Hidden Devices'}
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== devices.length && ` of ${devices.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</button>
|
||||||
|
{statusFilter === 'pending' && devices.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleClearAll}
|
||||||
|
className="text-muted-foreground hover:text-[#f85149] p-1.5 rounded transition-colors"
|
||||||
|
title={filtered.length !== devices.length ? `Remove ${filtered.length} filtered` : 'Clear all pending'}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Close">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||||
|
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={searchRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search name, IP, MAC, IEEE, service…"
|
||||||
|
className="w-full text-xs bg-[#0d1117] border border-border rounded px-7 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs" role="group" aria-label="Source filter">
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('all')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${sourceFilter === 'all' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('ip')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'ip' ? 'bg-[#a855f7]/20 text-[#a855f7]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
IP scan
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('zigbee')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'zigbee' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Zigbee
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
|
className="text-xs bg-[#0d1117] border border-border rounded px-2 py-1.5 outline-none focus:border-[#00d4ff]/50"
|
||||||
|
aria-label="Type filter"
|
||||||
|
>
|
||||||
|
<option value="all">All types</option>
|
||||||
|
{distinctTypes.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="flex rounded border border-border overflow-hidden text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('pending')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'pending' ? 'bg-[#00d4ff]/20 text-[#00d4ff]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Pending
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setStatusFilter('hidden')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors ${statusFilter === 'hidden' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Hidden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => selectMode ? exitSelectMode() : enterSelectMode()}
|
||||||
|
className={`text-xs px-2.5 py-1.5 rounded border transition-colors ${selectMode ? 'bg-[#00d4ff]/20 text-[#00d4ff] border-[#00d4ff]/50' : 'bg-[#0d1117] text-muted-foreground border-border hover:text-foreground'}`}
|
||||||
|
title="Toggle select mode (s)"
|
||||||
|
>
|
||||||
|
{selectMode ? 'Exit select' : 'Select mode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-10">
|
||||||
|
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground text-center py-10">
|
||||||
|
{devices.length === 0 ? `No ${statusFilter} devices` : 'No devices match filters'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-3">
|
||||||
|
{filtered.map((d) => (
|
||||||
|
<DeviceCard
|
||||||
|
key={d.id}
|
||||||
|
device={d}
|
||||||
|
selected={selectedIds.has(d.id)}
|
||||||
|
selectMode={selectMode}
|
||||||
|
highlighted={d.id === highlightId}
|
||||||
|
onClick={() => handleCardClick(d)}
|
||||||
|
cardRef={d.id === highlightId ? highlightRef : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selection action bar */}
|
||||||
|
{selectMode && (
|
||||||
|
<div className="px-4 py-2.5 border-t border-border bg-[#161b22] shrink-0 flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs text-muted-foreground mr-1">
|
||||||
|
{selectedIds.size} selected
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={selectAllVisible}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Select all visible ({filtered.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedIds(new Set())}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-2.5 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{statusFilter === 'pending' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkApprove}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Approve ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBulkHide}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Hide ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{statusFilter === 'hidden' && (
|
||||||
|
<button
|
||||||
|
onClick={handleBulkRestore}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 disabled:opacity-40 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Restore ({selectedIds.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<PendingDeviceModal
|
||||||
|
device={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onHide={handleHide}
|
||||||
|
onIgnore={handleIgnore}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceCardProps {
|
||||||
|
device: PendingDevice
|
||||||
|
selected: boolean
|
||||||
|
selectMode: boolean
|
||||||
|
highlighted: boolean
|
||||||
|
onClick: () => void
|
||||||
|
cardRef?: React.Ref<HTMLButtonElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||||
|
const source = inferSource(device)
|
||||||
|
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
||||||
|
const label = deviceLabel(device)
|
||||||
|
const sourceColor = source === 'zigbee' ? '#00d4ff' : '#a855f7'
|
||||||
|
const sourceLabel = source === 'zigbee' ? 'ZIGBEE' : (device.discovery_source ?? 'IP').toUpperCase()
|
||||||
|
const services = device.services ?? []
|
||||||
|
const visibleServices = services.slice(0, 4)
|
||||||
|
const moreServices = services.length - visibleServices.length
|
||||||
|
|
||||||
|
const borderClass = highlighted
|
||||||
|
? 'border-[#e3b341] bg-[#2d3748]'
|
||||||
|
: selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/5 shadow-[0_0_0_1px_rgba(0,212,255,0.4)] scale-[1.02]'
|
||||||
|
: 'border-border bg-[#161b22] hover:border-[#30363d] hover:bg-[#21262d]'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={cardRef}
|
||||||
|
onClick={onClick}
|
||||||
|
data-testid={`pending-card-${device.id}`}
|
||||||
|
className={`relative text-left rounded-lg border p-3 transition-all duration-150 ${borderClass}`}
|
||||||
|
>
|
||||||
|
{selectMode && selected && (
|
||||||
|
<CheckCircle2
|
||||||
|
size={18}
|
||||||
|
className="absolute top-2 right-2 text-[#00d4ff] fill-[#0d1117]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!selectMode && device.status === 'hidden' && (
|
||||||
|
<EyeOff size={14} className="absolute top-2 right-2 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-2 mb-2">
|
||||||
|
<div className="shrink-0 w-8 h-8 rounded bg-[#21262d] flex items-center justify-center text-foreground">
|
||||||
|
<Icon size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
||||||
|
>
|
||||||
|
{sourceLabel}
|
||||||
|
</span>
|
||||||
|
{device.suggested_type && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
{device.suggested_type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tech grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-x-2 gap-y-0.5 text-[11px] mb-2">
|
||||||
|
{device.ip && <InfoLine label="IP" value={device.ip} />}
|
||||||
|
{device.mac && <InfoLine label="MAC" value={device.mac} />}
|
||||||
|
{device.ieee_address && <InfoLine label="IEEE" value={device.ieee_address} />}
|
||||||
|
{device.hostname && <InfoLine label="Host" value={device.hostname} />}
|
||||||
|
{device.vendor && <InfoLine label="Vendor" value={device.vendor} />}
|
||||||
|
{device.model && <InfoLine label="Model" value={device.model} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Services */}
|
||||||
|
{visibleServices.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{visibleServices.map((s, i) => {
|
||||||
|
const color = serviceColor(s.port, s.category)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={`${s.port}-${s.protocol}-${i}`}
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${color}22`, color }}
|
||||||
|
title={`${s.service_name} (${s.protocol}/${s.port})`}
|
||||||
|
>
|
||||||
|
{s.service_name}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{moreServices > 0 && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded bg-[#21262d] text-muted-foreground">
|
||||||
|
+{moreServices}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoLine({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||||
|
<span className="text-muted-foreground shrink-0 w-12">{label}</span>
|
||||||
|
<span className="font-mono text-foreground truncate">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -33,8 +33,10 @@ export function SearchModal({ open, onClose, onOpenPending }: SearchModalProps)
|
|||||||
).slice(0, 6)
|
).slice(0, 6)
|
||||||
|
|
||||||
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
const pendingResults = q.length === 0 ? [] : pendingDevices.filter((d) =>
|
||||||
d.ip.toLowerCase().includes(q) ||
|
d.ip?.toLowerCase().includes(q) ||
|
||||||
d.hostname?.toLowerCase().includes(q) ||
|
d.hostname?.toLowerCase().includes(q) ||
|
||||||
|
d.friendly_name?.toLowerCase().includes(q) ||
|
||||||
|
d.ieee_address?.toLowerCase().includes(q) ||
|
||||||
d.services.some((s) =>
|
d.services.some((s) =>
|
||||||
s.service_name?.toLowerCase().includes(q) ||
|
s.service_name?.toLowerCase().includes(q) ||
|
||||||
s.category?.toLowerCase().includes(q)
|
s.category?.toLowerCase().includes(q)
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import modalStyles from './modal-interactive.module.css'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { hexToRgba, rgbaToHex8 } from '@/utils/colorUtils'
|
||||||
|
|
||||||
|
export type TextBorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
|
export interface TextFormData {
|
||||||
|
text: string
|
||||||
|
font: string
|
||||||
|
text_color: string
|
||||||
|
text_size: number
|
||||||
|
border_color: string
|
||||||
|
border_style: TextBorderStyle
|
||||||
|
border_width: number
|
||||||
|
background_color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const BORDER_STYLES: { value: TextBorderStyle; label: string; preview: string }[] = [
|
||||||
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
|
{ value: 'solid', label: 'Solid', preview: '───' },
|
||||||
|
{ value: 'dashed', label: 'Dashed', preview: '╌╌╌' },
|
||||||
|
{ value: 'dotted', label: 'Dotted', preview: '···' },
|
||||||
|
{ value: 'double', label: 'Double', preview: '═══' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 12, label: '12' },
|
||||||
|
{ value: 14, label: '14' },
|
||||||
|
{ value: 18, label: '18' },
|
||||||
|
{ value: 24, label: '24' },
|
||||||
|
{ value: 32, label: '32' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||||
|
{ value: 1, label: '1px' },
|
||||||
|
{ value: 2, label: '2px' },
|
||||||
|
{ value: 3, label: '3px' },
|
||||||
|
{ value: 4, label: '4px' },
|
||||||
|
{ value: 5, label: '5px' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const FONTS = [
|
||||||
|
{ value: 'inter', label: 'Inter (sans-serif)' },
|
||||||
|
{ value: 'mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'serif', label: 'Serif' },
|
||||||
|
{ value: 'sans', label: 'System Sans' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const DEFAULT_FORM: TextFormData = {
|
||||||
|
text: '',
|
||||||
|
font: 'inter',
|
||||||
|
text_color: '#e6edf3',
|
||||||
|
text_size: 14,
|
||||||
|
border_color: '#30363d',
|
||||||
|
border_style: 'none',
|
||||||
|
border_width: 1,
|
||||||
|
background_color: '#00000000',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: TextFormData) => void
|
||||||
|
onDelete?: () => void
|
||||||
|
initial?: Partial<TextFormData>
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Text' }: TextModalProps) {
|
||||||
|
const [form, setForm] = useState<TextFormData>({ ...DEFAULT_FORM, ...initial })
|
||||||
|
|
||||||
|
const set = <K extends keyof TextFormData>(key: K, value: TextFormData[K]) =>
|
||||||
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSubmit(form)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorFields = [
|
||||||
|
{ key: 'text_color' as const, label: 'Text' },
|
||||||
|
{ key: 'border_color' as const, label: 'Border' },
|
||||||
|
{ key: 'background_color' as const, label: 'Background' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
{/* Text content */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text</Label>
|
||||||
|
<textarea
|
||||||
|
value={form.text}
|
||||||
|
onChange={(e) => set('text', e.target.value)}
|
||||||
|
placeholder="Type text…"
|
||||||
|
rows={3}
|
||||||
|
className={`bg-[#21262d] border border-[#30363d] text-sm p-2 resize-y min-h-[60px] focus:outline-none focus:border-[#00d4ff] ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Font (Police) */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Police</Label>
|
||||||
|
<Select value={form.font} onValueChange={(v: string | null) => set('font', v ?? 'inter')}>
|
||||||
|
<SelectTrigger className={`bg-[#21262d] border-[#30363d] text-sm h-8 cursor-pointer ${modalStyles['modal-interactive']} ${modalStyles['modal-radius']}`}>
|
||||||
|
<SelectValue>
|
||||||
|
{FONTS.find((f) => f.value === form.font)?.label ?? form.font}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-[#21262d] border-[#30363d]">
|
||||||
|
{FONTS.map((f) => (
|
||||||
|
<SelectItem key={f.value} value={f.value} className="text-sm">
|
||||||
|
{f.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colors */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{colorFields.map(({ key, label }) => {
|
||||||
|
const { hex6, alpha } = hexToRgba(form[key])
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col gap-1 items-center">
|
||||||
|
<label
|
||||||
|
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
|
||||||
|
style={{ borderColor: '#30363d' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={hex6}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(e.target.value, alpha))}
|
||||||
|
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
|
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={alpha}
|
||||||
|
onChange={(e) => set(key, rgbaToHex8(hex6, Number(e.target.value)))}
|
||||||
|
className="w-full h-1 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title={`Opacity: ${alpha}%`}
|
||||||
|
/>
|
||||||
|
<span className="text-[9px] text-muted-foreground/60">{label} {alpha}%</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text size */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Size</Label>
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{TEXT_SIZES.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_size === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('text_size', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
fontSize: Math.min(value, 16),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border style */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_STYLES.map(({ value, label, preview }) => {
|
||||||
|
const isSelected = form.border_style === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
onClick={() => set('border_style', value)}
|
||||||
|
className={`flex flex-col items-center justify-center h-10 rounded text-xs gap-0.5 transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] leading-none">{preview}</span>
|
||||||
|
<span className="text-[9px]">{label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Border width (only when style != none) */}
|
||||||
|
{form.border_style !== 'none' && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Border Width</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{BORDER_WIDTHS.map(({ value, label }) => {
|
||||||
|
const isSelected = form.border_width === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('border_width', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded text-xs transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
{onDelete && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10 cursor-pointer"
|
||||||
|
onClick={() => { onDelete(); onClose() }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className={`cursor-pointer ${modalStyles['modal-cancel-hover']}`} onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer">
|
||||||
|
{title === 'Add Text' ? 'Add' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState, type KeyboardEvent } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Check } from 'lucide-react'
|
import { Check, Pencil } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
import { THEMES, THEME_ORDER, type ThemeId } from '@/utils/themes'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { CustomStyleModal } from './CustomStyleModal'
|
||||||
|
|
||||||
// Node-type accent colors to display as preview swatches
|
// Node-type accent colors to display as preview swatches
|
||||||
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
const PREVIEW_TYPES = ['isp', 'server', 'proxmox', 'switch', 'iot'] as const
|
||||||
@@ -14,74 +15,108 @@ interface ThemeCardProps {
|
|||||||
themeId: ThemeId
|
themeId: ThemeId
|
||||||
selected: boolean
|
selected: boolean
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
|
onKeyDown?: (event: KeyboardEvent<HTMLButtonElement>) => void
|
||||||
|
buttonRef?: (element: HTMLButtonElement | null) => void
|
||||||
|
onEdit?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ themeId, selected, onClick }: ThemeCardProps) {
|
function ThemeCard({ themeId, selected, onClick, onKeyDown, buttonRef, onEdit }: ThemeCardProps) {
|
||||||
|
const { customStyle } = useThemeStore()
|
||||||
const preset = THEMES[themeId]
|
const preset = THEMES[themeId]
|
||||||
const c = preset.colors
|
const c = preset.colors
|
||||||
|
const isCustom = themeId === 'custom'
|
||||||
|
|
||||||
|
// For custom theme, use defined node colors for preview swatches
|
||||||
|
const swatchColors = isCustom
|
||||||
|
? PREVIEW_TYPES.map((t) => customStyle.nodes[t]?.borderColor ?? c.nodeAccents[t].border)
|
||||||
|
: PREVIEW_TYPES.map((t) => c.nodeAccents[t].border)
|
||||||
|
|
||||||
|
const ethernetColor = isCustom
|
||||||
|
? (customStyle.edges['ethernet']?.color ?? c.edgeColors.ethernet)
|
||||||
|
: c.edgeColors.ethernet
|
||||||
|
const wifiColor = isCustom
|
||||||
|
? (customStyle.edges['wifi']?.color ?? c.edgeColors.wifi)
|
||||||
|
: c.edgeColors.wifi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div className="relative w-full h-full">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClick}
|
ref={buttonRef}
|
||||||
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full"
|
type="button"
|
||||||
style={{
|
onClick={onClick}
|
||||||
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
onKeyDown={onKeyDown}
|
||||||
background: c.canvasBackground,
|
className="relative rounded-xl border-2 p-3 text-left transition-all duration-150 focus:outline-none w-full h-full flex flex-col"
|
||||||
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
style={{
|
||||||
}}
|
borderColor: selected ? c.nodeAccents.isp.border : c.handleBackground,
|
||||||
>
|
background: c.canvasBackground,
|
||||||
{/* Selected checkmark */}
|
boxShadow: selected ? `0 0 0 1px ${c.nodeAccents.isp.border}44, 0 0 12px ${c.nodeAccents.isp.border}22` : 'none',
|
||||||
{selected && (
|
}}
|
||||||
<span
|
|
||||||
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
|
||||||
style={{ background: c.nodeAccents.isp.border }}
|
|
||||||
>
|
|
||||||
<Check size={10} style={{ color: c.canvasBackground }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mini canvas preview */}
|
|
||||||
<div
|
|
||||||
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
|
||||||
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
|
||||||
>
|
>
|
||||||
{/* Node accent dots */}
|
{/* Selected checkmark */}
|
||||||
<div className="flex gap-1 items-center flex-wrap">
|
{selected && (
|
||||||
{PREVIEW_TYPES.map((type) => (
|
<span
|
||||||
<span
|
className="absolute top-2 right-2 flex items-center justify-center w-4 h-4 rounded-full"
|
||||||
key={type}
|
style={{ background: c.nodeAccents.isp.border }}
|
||||||
className="w-2.5 h-2.5 rounded-full shrink-0"
|
>
|
||||||
style={{ backgroundColor: c.nodeAccents[type].border }}
|
<Check size={10} style={{ color: c.canvasBackground }} />
|
||||||
/>
|
</span>
|
||||||
))}
|
)}
|
||||||
</div>
|
|
||||||
{/* Edge line */}
|
{/* Mini canvas preview */}
|
||||||
<div style={{ height: 2, background: c.edgeColors.ethernet, width: '80%', borderRadius: 2 }} />
|
|
||||||
{/* Wifi dashed line */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
className="rounded-md mb-2.5 flex flex-col gap-1.5 p-2"
|
||||||
height: 1,
|
style={{ background: c.nodeCardBackground, border: `1px solid ${c.handleBackground}` }}
|
||||||
width: '55%',
|
>
|
||||||
backgroundImage: `repeating-linear-gradient(90deg, ${c.edgeColors.wifi} 0 5px, transparent 5px 8px)`,
|
<div className="flex gap-1 items-center flex-wrap">
|
||||||
}}
|
{swatchColors.map((color, i) => (
|
||||||
/>
|
<span
|
||||||
</div>
|
key={i}
|
||||||
|
className="w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 2, background: ethernetColor, width: '80%', borderRadius: 2 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 1,
|
||||||
|
width: '55%',
|
||||||
|
backgroundImage: `repeating-linear-gradient(90deg, ${wifiColor} 0 5px, transparent 5px 8px)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
<div
|
||||||
<div
|
className="text-sm font-semibold leading-tight wrap-break-word"
|
||||||
className="text-xs font-semibold leading-tight"
|
style={{ color: c.nodeLabelColor }}
|
||||||
style={{ color: c.nodeLabelColor }}
|
>
|
||||||
>
|
{preset.label}
|
||||||
{preset.label}
|
</div>
|
||||||
</div>
|
<div
|
||||||
<div
|
className="text-xs leading-snug mt-1 line-clamp-3 whitespace-normal wrap-break-word overflow-hidden min-h-12"
|
||||||
className="text-[10px] leading-snug mt-0.5 line-clamp-2"
|
style={{ color: c.nodeSubtextColor }}
|
||||||
style={{ color: c.nodeSubtextColor }}
|
>
|
||||||
>
|
{preset.description}
|
||||||
{preset.description}
|
</div>
|
||||||
</div>
|
</button>
|
||||||
</button>
|
|
||||||
|
{/* Edit button — only for custom theme */}
|
||||||
|
{isCustom && onEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit() }}
|
||||||
|
title="Edit custom style"
|
||||||
|
className="absolute bottom-2 right-2 flex items-center justify-center w-6 h-6 rounded-md transition-colors"
|
||||||
|
style={{
|
||||||
|
background: c.nodeCardBackground,
|
||||||
|
color: c.nodeLabelColor,
|
||||||
|
border: `1px solid ${c.handleBackground}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +128,8 @@ interface ThemeModalProps {
|
|||||||
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
||||||
const { activeTheme, setTheme } = useThemeStore()
|
const { activeTheme, setTheme } = useThemeStore()
|
||||||
const { markUnsaved } = useCanvasStore()
|
const { markUnsaved } = useCanvasStore()
|
||||||
|
const cardRefs = useRef<Array<HTMLButtonElement | null>>([])
|
||||||
|
const [customStyleOpen, setCustomStyleOpen] = useState(false)
|
||||||
|
|
||||||
// Capture the theme that was active when the modal opened
|
// Capture the theme that was active when the modal opened
|
||||||
const [originalTheme] = useState<ThemeId>(activeTheme)
|
const [originalTheme] = useState<ThemeId>(activeTheme)
|
||||||
@@ -100,68 +137,96 @@ export function ThemeModal({ open, onClose }: ThemeModalProps) {
|
|||||||
|
|
||||||
const handleSelect = (id: ThemeId) => {
|
const handleSelect = (id: ThemeId) => {
|
||||||
setSelected(id)
|
setSelected(id)
|
||||||
// Live-preview the selected theme on the canvas
|
|
||||||
setTheme(id)
|
setTheme(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCardKeyDown = (index: number) => (event: KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
handleApply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||||
|
const nextIndex = (index + direction + THEME_ORDER.length) % THEME_ORDER.length
|
||||||
|
const nextTheme = THEME_ORDER[nextIndex]
|
||||||
|
|
||||||
|
setSelected(nextTheme)
|
||||||
|
setTheme(nextTheme)
|
||||||
|
|
||||||
|
const nextCard = cardRefs.current[nextIndex]
|
||||||
|
if (!nextCard) return
|
||||||
|
|
||||||
|
nextCard.focus({ preventScroll: true })
|
||||||
|
nextCard.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
||||||
|
}
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
setTheme(selected)
|
setTheme(selected)
|
||||||
markUnsaved()
|
markUnsaved()
|
||||||
onClose()
|
onClose()
|
||||||
toast.info('Style applied — save your canvas to make it permanent', {
|
toast.info('Style applied — save your canvas to make it permanent', { duration: 5000 })
|
||||||
duration: 5000,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
// Revert to the original theme
|
|
||||||
setTheme(originalTheme)
|
setTheme(originalTheme)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
<>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] w-[90vw] max-w-4xl">
|
<Dialog open={open} onOpenChange={(o) => { if (!o) handleCancel() }}>
|
||||||
<DialogHeader>
|
<DialogContent className="bg-[#161b22] border-[#30363d] w-fit max-w-[calc(100%-2rem)] sm:max-w-[50vw]">
|
||||||
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
<DialogHeader>
|
||||||
</DialogHeader>
|
<DialogTitle className="text-sm font-semibold">Choose Canvas Style</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-3 py-1">
|
<div className="flex items-stretch flex-nowrap gap-3 py-1 overflow-x-auto overflow-y-hidden pb-2 pr-1">
|
||||||
{THEME_ORDER.map((id) => (
|
{THEME_ORDER.map((id, index) => (
|
||||||
<ThemeCard
|
<div key={id} className="shrink-0 w-30 md:w-24 h-full">
|
||||||
key={id}
|
<ThemeCard
|
||||||
themeId={id}
|
themeId={id}
|
||||||
selected={selected === id}
|
selected={selected === id}
|
||||||
onClick={() => handleSelect(id)}
|
onClick={() => handleSelect(id)}
|
||||||
/>
|
onKeyDown={handleCardKeyDown(index)}
|
||||||
))}
|
buttonRef={(element) => { cardRefs.current[index] = element }}
|
||||||
</div>
|
onEdit={id === 'custom' ? () => setCustomStyleOpen(true) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-1">
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
style={
|
style={
|
||||||
selected !== 'default'
|
selected !== 'default'
|
||||||
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
? { background: THEMES[selected].colors.nodeAccents.isp.border }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
>
|
>
|
||||||
Apply Style
|
Apply Style
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<CustomStyleModal open={customStyleOpen} onClose={() => setCustomStyleOpen(false)} />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { CustomStyleModal } from '../CustomStyleModal'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
describe('CustomStyleModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } })
|
||||||
|
useCanvasStore.setState({ hasUnsavedChanges: false })
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<CustomStyleModal open={false} onClose={vi.fn()} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders title and tabs', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Custom Style Editor')).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts with empty selection placeholder', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
expect(screen.getByText(/Select a node type/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to edges tab and shows the right placeholder', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting a node type opens the node editor', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect(screen.getByText(/Apply to existing/)).toBeDefined()
|
||||||
|
expect(screen.getByText('Default size')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined()
|
||||||
|
expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply-to-existing node button calls store and toasts', () => {
|
||||||
|
const applyTypeNodeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeNodeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ }))
|
||||||
|
expect(applyTypeNodeStyle).toHaveBeenCalledOnce()
|
||||||
|
expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router')
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply-to-existing edge button calls store and toasts', () => {
|
||||||
|
const applyTypeEdgeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||||
|
expect(applyTypeEdgeStyle).toHaveBeenCalledOnce()
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
|
||||||
|
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
const applyAllCustomStyles = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved, applyAllCustomStyles })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' }))
|
||||||
|
expect(applyAllCustomStyles).toHaveBeenCalledOnce()
|
||||||
|
expect(markUnsaved).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Cancel button closes without saving', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
const markUnsaved = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved })
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
expect(markUnsaved).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing path style updates the edge draft', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
const smoothBtn = screen.getByRole('button', { name: 'Smooth' })
|
||||||
|
fireEvent.click(smoothBtn)
|
||||||
|
// The clicked button should now be styled selected (cyan border)
|
||||||
|
expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('changing width input updates node draft', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
const widthInputs = screen.getAllByRole('spinbutton')
|
||||||
|
fireEvent.change(widthInputs[0], { target: { value: '250' } })
|
||||||
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
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 ─────────────────────────────────────────────────────────
|
// ── VLAN ID field ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show VLAN ID field for ethernet type', () => {
|
it('does not show VLAN ID field for ethernet type', () => {
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ describe('GroupRectModal', () => {
|
|||||||
expect(submitted.z_order).toBe(1)
|
expect(submitted.z_order).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exposes aria-labels on grid buttons and select triggers', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByLabelText('Font selector')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Z-order selector')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Text position ↘')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Label position Inside')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Border style Solid')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Border width 1px')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('calls onClose when Cancel is clicked', () => {
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
const onClose = vi.fn()
|
const onClose = vi.fn()
|
||||||
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
@@ -308,3 +318,24 @@ describe('GroupRectModal', () => {
|
|||||||
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
expect(screen.getByText(/Background 5%/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('GroupRectModal font label rendering', () => {
|
||||||
|
it('renders the human font label in the Select trigger (default inter)', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
const trigger = screen.getByLabelText('Font selector')
|
||||||
|
expect(trigger.textContent).toContain('Inter (sans-serif)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to raw value when font is unknown', () => {
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ font: 'comic-sans-9000' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const trigger = screen.getByLabelText('Font selector')
|
||||||
|
expect(trigger.textContent).toContain('comic-sans-9000')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ describe('NodeModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalledOnce()
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Delete confirm ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('deletes and closes when Delete confirm is accepted', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ _delete: true }))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: bare-if without braces used to call onClose() unconditionally,
|
||||||
|
// closing the modal even when the user cancelled the confirm dialog.
|
||||||
|
it('does not delete or close when Delete confirm is cancelled', () => {
|
||||||
|
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||||
|
const { onClose, onSubmit } = renderModal({ title: 'Edit Node', initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
|
expect(onClose).not.toHaveBeenCalled()
|
||||||
|
confirmSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Label validation ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -251,53 +273,84 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('toggles container_mode on click', () => {
|
it('toggles container_mode on click', () => {
|
||||||
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
const { onSubmit } = renderModal({ initial: { ...BASE, type: 'proxmox', container_mode: true } })
|
||||||
fireEvent.click(screen.getByRole('switch'))
|
fireEvent.click(screen.getByRole('switch', { name: 'Container Mode' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).container_mode).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Parent container ──────────────────────────────────────────────────
|
// ── Show services toggle (modal-only) ───────────────────────────────
|
||||||
|
|
||||||
const parentContainerVisibleTypes = ['proxmox', 'vm', 'lxc', 'docker_host', 'isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic'] as const
|
it('shows Show Services toggle for regular nodes', () => {
|
||||||
const parentContainerHiddenTypes = ['groupRect', 'group'] as const
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.getByText('Show Services')).toBeDefined()
|
||||||
|
expect(screen.getByRole('switch', { name: 'Show Services' })).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('shows Parent Container for %s type when options are provided', (type) => {
|
it('hides Show Services toggle for groupRect', () => {
|
||||||
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
|
expect(screen.queryByText('Show Services')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits custom_colors.show_services=true when toggled on', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
const data = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(data.custom_colors?.show_services).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps default colors hint visible when Show Services is toggled on', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not show Appearance reset when only Show Services is set', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Show Services' }))
|
||||||
|
expect(screen.queryByText('Reset to defaults')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Parent Container selector ─────────────────────────────────────────
|
||||||
|
|
||||||
|
it('does not render Parent Container for non-child types', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type },
|
initial: BASE,
|
||||||
parentContainerNodes: [{ id: 'c1', label: 'Container 01' }],
|
parentCandidates: [{ id: 'p1', label: 'Proxmox', type: 'proxmox' }],
|
||||||
|
})
|
||||||
|
expect(screen.queryByText('Parent Container')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
expect(screen.getByText('Container 01')).toBeDefined()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each(parentContainerHiddenTypes)('hides Parent Container for %s type even when options are provided', (type) => {
|
it('renders Parent Container for docker_container when only an LXC candidate exists', () => {
|
||||||
renderModal({ initial: { ...BASE, type }, parentContainerNodes: [{ id: 'c1', label: 'Container 01' }] })
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each(parentContainerVisibleTypes)('hides Parent Container for %s type when no container options are available', (type) => {
|
|
||||||
renderModal({ initial: { ...BASE, type } })
|
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('docker_container shows only docker_host parents', () => {
|
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
initial: { ...BASE, type: 'docker_container' },
|
||||||
parentContainerNodes: [
|
parentCandidates: [{ id: 'lxc1', label: 'My LXC', type: 'lxc' }],
|
||||||
{ id: 'h1', label: 'My Docker Host', nodeType: 'docker_host' },
|
|
||||||
{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' },
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
expect(screen.getByText('My Docker Host')).toBeDefined()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
expect(screen.queryByText('My Proxmox')).toBeNull()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('docker_container hides Parent Container when no docker_host is available', () => {
|
it('renders Parent Container for lxc when proxmox candidate exists', () => {
|
||||||
renderModal({
|
renderModal({
|
||||||
initial: { ...BASE, type: 'docker_container' },
|
initial: { ...BASE, type: 'lxc' },
|
||||||
parentContainerNodes: [{ id: 'p1', label: 'My Proxmox', nodeType: 'proxmox' }],
|
parentCandidates: [{ id: 'px1', label: 'PVE', type: 'proxmox' }],
|
||||||
})
|
})
|
||||||
expect(screen.queryByText('Parent Container')).toBeNull()
|
expect(screen.getByText('Parent Container')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Appearance ────────────────────────────────────────────────────────
|
// ── Appearance ────────────────────────────────────────────────────────
|
||||||
@@ -345,18 +398,67 @@ describe('NodeModal', () => {
|
|||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults bottom_handles to 1', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect(selects()[2].value).toBe('1')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills bottom_handles from initial', () => {
|
it('pre-fills bottom_handles from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
||||||
expect(selects()[2].value).toBe('3')
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
expect(slider.value).toBe('3')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits updated bottom_handles', () => {
|
it('submits updated bottom_handles', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
fireEvent.change(selects()[2], { target: { value: '4' } })
|
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
||||||
|
fireEvent.change(slider, { target: { value: '12' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(4)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('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(52)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('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 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const zigbeeTypes = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] as const
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('hides Check Method for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Check Method')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('hides Check Target for %s type', (type) => {
|
||||||
|
renderModal({ initial: { ...BASE, type } })
|
||||||
|
expect(screen.queryByText('Check Target')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(zigbeeTypes)('submits check_method=none for %s type', (type) => {
|
||||||
|
const { onSubmit } = renderModal({ initial: { ...BASE, type, label: 'Zigbee Node' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).check_method).toBe('none')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { PendingDevicesModal } from '../PendingDevicesModal'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
vi.mock('@/stores/canvasStore')
|
||||||
|
|
||||||
|
const mockBulkApprove = vi.fn()
|
||||||
|
const mockBulkHide = vi.fn()
|
||||||
|
const mockRestore = vi.fn()
|
||||||
|
const mockBulkRestore = vi.fn()
|
||||||
|
const mockApprove = vi.fn()
|
||||||
|
const mockHide = vi.fn()
|
||||||
|
const mockPending = vi.fn()
|
||||||
|
const mockHidden = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
scanApi: {
|
||||||
|
pending: (...a: unknown[]) => mockPending(...a),
|
||||||
|
hidden: (...a: unknown[]) => mockHidden(...a),
|
||||||
|
clearPending: vi.fn().mockResolvedValue({}),
|
||||||
|
approve: (...a: unknown[]) => mockApprove(...a),
|
||||||
|
hide: (...a: unknown[]) => mockHide(...a),
|
||||||
|
ignore: vi.fn().mockResolvedValue({}),
|
||||||
|
bulkApprove: (...a: unknown[]) => mockBulkApprove(...a),
|
||||||
|
bulkHide: (...a: unknown[]) => mockBulkHide(...a),
|
||||||
|
restore: (...a: unknown[]) => mockRestore(...a),
|
||||||
|
bulkRestore: (...a: unknown[]) => mockBulkRestore(...a),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||||
|
|
||||||
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||||
|
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
||||||
|
device ? <div data-testid="approval-modal" /> : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const DEVICE_IP = {
|
||||||
|
id: 'dev-a',
|
||||||
|
ip: '192.168.1.10',
|
||||||
|
hostname: 'host-a',
|
||||||
|
mac: 'aa:bb:cc:dd:ee:01',
|
||||||
|
os: null,
|
||||||
|
services: [{ port: 80, protocol: 'tcp', service_name: 'http' }],
|
||||||
|
suggested_type: 'server',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'arp',
|
||||||
|
discovered_at: '2026-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_ZIGBEE = {
|
||||||
|
id: 'dev-b',
|
||||||
|
ip: null,
|
||||||
|
hostname: null,
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'iot',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'zigbee',
|
||||||
|
ieee_address: '0x00124b001234abcd',
|
||||||
|
friendly_name: 'living-room-bulb',
|
||||||
|
vendor: 'Philips',
|
||||||
|
model: 'Hue White',
|
||||||
|
discovered_at: '2026-01-02T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(useCanvasStore).mockReturnValue({
|
||||||
|
addNode: vi.fn(),
|
||||||
|
scanEventTs: 0,
|
||||||
|
} as unknown as ReturnType<typeof useCanvasStore>)
|
||||||
|
// setState is used by injectAutoEdges
|
||||||
|
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||||
|
mockHidden.mockResolvedValue({ data: [] })
|
||||||
|
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||||
|
mockHide.mockResolvedValue({ data: {} })
|
||||||
|
mockBulkApprove.mockResolvedValue({
|
||||||
|
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
||||||
|
})
|
||||||
|
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||||
|
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||||
|
mockBulkRestore.mockResolvedValue({ data: { restored: 1, skipped: 0 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PendingDevicesModal', () => {
|
||||||
|
it('loads and renders pending devices on open', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('living-room-bulb')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows source chip ZIGBEE for zigbee device', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('ZIGBEE')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by search query', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'living' } })
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (zigbee only)', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Zigbee' }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by suggested type', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'server' } })
|
||||||
|
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switches to hidden status loads hidden devices', async () => {
|
||||||
|
mockHidden.mockResolvedValue({
|
||||||
|
data: [{ ...DEVICE_IP, id: 'h1', hostname: 'hidden-host', status: 'hidden' }],
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hidden' }))
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-h1')).toBeInTheDocument())
|
||||||
|
expect(mockHidden).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens approval modal when card is clicked outside select mode', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles selection in select mode instead of opening approval', 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'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('select all visible selects only filtered devices', async () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Search/), { target: { value: 'host-a' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Select all visible/ }))
|
||||||
|
expect(screen.getByText('1 selected')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk approve calls API with selected ids', 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(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk hide calls API with selected ids', 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.getByRole('button', { name: /Hide \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not load when closed', () => {
|
||||||
|
render(<PendingDevicesModal {...baseProps} open={false} />)
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects initialStatus=hidden', async () => {
|
||||||
|
mockHidden.mockResolvedValue({ data: [{ ...DEVICE_IP, hostname: 'hidden-host', status: 'hidden' }] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} initialStatus="hidden" />)
|
||||||
|
await waitFor(() => expect(mockHidden).toHaveBeenCalled())
|
||||||
|
expect(mockPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a hidden card restores it instead of opening approval', 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.getByTestId('pending-card-dev-a'))
|
||||||
|
await waitFor(() => expect(mockRestore).toHaveBeenCalledWith('dev-a'))
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('bulk restore in hidden mode calls API with selected ids', 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.click(screen.getByRole('button', { name: /Restore \(1\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkRestore).toHaveBeenCalledWith(['dev-a']))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { TextModal, type TextFormData } from '../TextModal'
|
||||||
|
|
||||||
|
describe('TextModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TextModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
|
||||||
|
)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders form fields when open', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByPlaceholderText('Type text…')).toBeDefined()
|
||||||
|
expect(screen.getByText('Add Text')).toBeDefined()
|
||||||
|
expect(screen.getByText('Police')).toBeDefined()
|
||||||
|
expect(screen.getByText('Border Style')).toBeDefined()
|
||||||
|
expect(screen.getByText('Size')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Edit Text title when provided', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Text" />)
|
||||||
|
expect(screen.getByText('Edit Text')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onSubmit with form data on submit', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…')
|
||||||
|
fireEvent.change(ta, { target: { value: 'Hello' } })
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
expect(onSubmit).toHaveBeenCalledOnce()
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as TextFormData
|
||||||
|
expect(submitted.text).toBe('Hello')
|
||||||
|
expect(submitted.font).toBe('inter')
|
||||||
|
expect(submitted.border_style).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides Border Width when style is none, shows when not', () => {
|
||||||
|
render(<TextModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.queryByText('Border Width')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByTitle('Solid'))
|
||||||
|
expect(screen.getByText('Border Width')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Delete button and calls handlers when provided', () => {
|
||||||
|
const onDelete = vi.fn()
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
|
||||||
|
fireEvent.click(screen.getByText('Delete'))
|
||||||
|
expect(onDelete).toHaveBeenCalledOnce()
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills from initial prop', () => {
|
||||||
|
render(
|
||||||
|
<TextModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
initial={{ text: 'Pre-filled', text_size: 24, font: 'mono' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
const ta = screen.getByPlaceholderText('Type text…') as HTMLTextAreaElement
|
||||||
|
expect(ta.value).toBe('Pre-filled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancel calls onClose', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<TextModal open onClose={onClose} onSubmit={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByText('Cancel'))
|
||||||
|
expect(onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/* SidebarItem pointer on hover */
|
||||||
|
.sidebar-pointer:hover {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
/* Consistent border radius for all modal input/select/button elements */
|
||||||
|
.modal-radius {
|
||||||
|
border-radius: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pointer cursor for close X */
|
||||||
|
.modal-close-pointer:hover {
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle hover background for cancel button */
|
||||||
|
.modal-cancel-hover:hover {
|
||||||
|
background: #21262d !important;
|
||||||
|
}
|
||||||
|
/* Shared hover/focus border effect for interactive modal elements */
|
||||||
|
.modal-interactive {
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.modal-interactive:hover,
|
||||||
|
.modal-interactive:focus {
|
||||||
|
border-color: #8b949e !important;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
@@ -2,11 +2,11 @@ import { createElement, useState } from 'react'
|
|||||||
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
|
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
import { NODE_TYPE_LABELS, STATUS_COLORS, type ServiceInfo, type NodeData, type NodeProperty } from '@/types'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
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 { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
|
|
||||||
@@ -85,7 +85,8 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
const { data } = node
|
const { data } = node
|
||||||
const services = data.services ?? []
|
const services = data.services ?? []
|
||||||
const statusColor = STATUS_COLORS[data.status]
|
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 = () => {
|
const handleDelete = () => {
|
||||||
if (confirm(`Delete "${data.label}"?`)) {
|
if (confirm(`Delete "${data.label}"?`)) {
|
||||||
@@ -200,7 +201,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
<aside className="w-72 shrink-0 flex flex-col border-l border-border bg-[#161b22] overflow-y-auto">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
<span className="font-semibold text-sm text-foreground truncate">{data.label}</span>
|
||||||
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors">
|
<button aria-label="Close panel" onClick={() => setSelectedNode(null)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||||
<X size={16} />
|
<X size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,18 +224,31 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.ip && (
|
{ipAddresses.length > 0 && (
|
||||||
<div className="flex justify-between gap-2 items-baseline">
|
<div className="flex justify-between gap-2 items-start">
|
||||||
<span className="text-muted-foreground text-xs shrink-0">IP Address</span>
|
<span className="text-muted-foreground text-xs shrink-0">{ipAddresses.length > 1 ? 'IP Addresses' : '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}>
|
<div className="flex flex-wrap justify-end items-center gap-x-2 gap-y-1 max-w-[65%]">
|
||||||
{data.ip}<ExternalLink size={10} className="shrink-0" />
|
{ipAddresses.map((ip, index) => (
|
||||||
</a>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
{data.mac && <DetailRow label="MAC" value={data.mac} mono />}
|
||||||
{data.os && <DetailRow label="OS" value={data.os} />}
|
{data.os && <DetailRow label="OS" value={data.os} />}
|
||||||
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
{data.check_method && <DetailRow label="Check" value={data.check_method} mono />}
|
||||||
{data.last_seen && <DetailRow label="Last Seen" value={new Date(data.last_seen.endsWith('Z') ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
{data.last_seen && <DetailRow label="Last Seen" value={new Date(/[Zz]|[+-]\d{2}:?\d{2}$/.test(data.last_seen) ? data.last_seen : data.last_seen + 'Z').toLocaleString()} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Properties section */}
|
{/* Properties section */}
|
||||||
@@ -243,7 +257,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
<span className="text-xs text-muted-foreground">Properties{properties.length > 0 ? ` (${properties.length})` : ''}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
onClick={() => { setAddingProp((v) => !v); setEditingPropIndex(null) }}
|
||||||
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
|
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus size={10} /> Add
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
@@ -289,7 +303,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
<div className="px-4 py-3 border-t border-border">
|
<div className="px-4 py-3 border-t border-border">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
<span className="text-xs text-muted-foreground">Services{services.length > 0 ? ` (${services.length})` : ''}</span>
|
||||||
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors">
|
<button onClick={() => { setAddingForNode((v) => v === node.id ? null : node.id); setEditingFor(null) }} className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer">
|
||||||
<Plus size={10} /> Add
|
<Plus size={10} /> Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,10 +330,10 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
<div className="mt-auto flex gap-2 px-4 py-3 border-t border-border">
|
||||||
<Button size="sm" variant="secondary" className="flex-1 gap-1.5" onClick={() => onEdit(node.id)}>
|
<Button size="sm" variant="secondary" className="flex-1 gap-1.5 cursor-pointer" onClick={() => onEdit(node.id)}>
|
||||||
<Edit size={14} /> Edit
|
<Edit size={14} /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="destructive" className="gap-1.5" aria-label="Delete node" onClick={handleDelete}>
|
<Button size="sm" variant="destructive" className="gap-1.5 cursor-pointer" aria-label="Delete node" onClick={handleDelete}>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -662,82 +676,80 @@ const CATEGORY_COLORS: Record<string, string> = {
|
|||||||
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
function ServiceBadge({ svc, host, onEdit, onRemove }: { svc: ServiceInfo; host?: string; onEdit: () => void; onRemove: () => void }) {
|
||||||
const url = getServiceUrl(svc, host)
|
const url = getServiceUrl(svc, host)
|
||||||
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
const color = CATEGORY_COLORS[svc.category ?? ''] ?? '#8b949e'
|
||||||
const hasPort = svc.port != null
|
|
||||||
const portLabel = hasPort ? String(svc.port) : ''
|
|
||||||
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
const pathLabel = svc.path?.trim() ? svc.path.trim() : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="group flex items-center gap-1 border rounded-md text-xs transition-colors px-2 py-1.5 min-w-0"
|
className="group flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border text-xs transition-colors min-w-0"
|
||||||
style={{ background: '#21262d', borderColor: '#30363d' }}
|
style={{ background: '#21262d', borderColor: '#30363d' }}
|
||||||
>
|
>
|
||||||
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||||
{url ? (
|
<span className="shrink-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium truncate min-w-0 flex-1"
|
|
||||||
style={{ color }}
|
|
||||||
title={svc.service_name}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{svc.service_name}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="font-medium truncate min-w-0 flex-1"
|
|
||||||
style={{ color }}
|
|
||||||
title={svc.service_name}
|
|
||||||
>
|
|
||||||
{svc.service_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
|
||||||
{pathLabel && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span
|
|
||||||
className="truncate text-[#8b949e] max-w-[80px]"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-label={pathLabel}
|
|
||||||
>
|
|
||||||
{pathLabel}
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top">{pathLabel}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
{hasPort && (
|
|
||||||
<span className="font-mono text-[#8b949e] shrink-0">{portLabel}/{svc.protocol}</span>
|
|
||||||
)}
|
|
||||||
{url ? (
|
{url ? (
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex w-2.5 h-2.5 items-center justify-center shrink-0"
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
aria-label="Open service link"
|
style={{ color }}
|
||||||
style={{ color: 'inherit' }}
|
title={svc.service_name}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-medium truncate min-w-0 flex-1"
|
||||||
|
style={{ color }}
|
||||||
|
title={svc.service_name}
|
||||||
|
>
|
||||||
|
{svc.service_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pathLabel && (
|
||||||
|
<span
|
||||||
|
className="shrink-0 text-[#8b949e] text-right w-16 truncate"
|
||||||
|
title={pathLabel}
|
||||||
|
>
|
||||||
|
{pathLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{svc.port != null && (
|
||||||
|
<span className="font-mono text-[#8b949e]">
|
||||||
|
{svc.port}/{svc.protocol}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{url ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex w-2.5 h-2.5 items-center justify-center"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<ExternalLink size={10} className="text-muted-foreground" />
|
<ExternalLink size={10} className="text-muted-foreground" />
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="w-2.5 shrink-0" />
|
<span className="w-2.5" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onEdit() }}
|
||||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5"
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#00d4ff] ml-0.5 cursor-pointer"
|
||||||
title="Edit service"
|
title="Edit service"
|
||||||
>
|
>
|
||||||
<Pencil size={10} />
|
<Pencil size={10} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onRemove() }}
|
||||||
className="opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5"
|
className="opacity-0 group-hover:opacity-100 transition-opacity text-[#8b949e] hover:text-[#f85149] ml-0.5 cursor-pointer"
|
||||||
title="Remove service"
|
title="Remove service"
|
||||||
>
|
>
|
||||||
<X size={10} />
|
<X size={10} />
|
||||||
|
|||||||
@@ -1,29 +1,36 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
|
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, LogOut, Network, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { designsApi, scanApi, settingsApi } from '@/api/client'
|
||||||
|
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||||
|
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
|
||||||
|
import type { Design } from '@/types'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
type SidebarView = 'canvas' | 'pending' | 'hidden' | 'history' | 'settings'
|
type SidebarView = 'canvas' | 'history' | 'settings'
|
||||||
|
|
||||||
const ALL_VIEWS = [
|
const PENDING_TRIGGERS: { kind: 'pending' | 'hidden'; icon: typeof ScanLine; label: string }[] = [
|
||||||
{ id: 'canvas' as SidebarView, icon: LayoutDashboard, label: 'Canvas' },
|
{ kind: 'pending', icon: ScanLine, label: 'Pending Devices' },
|
||||||
{ id: 'pending' as SidebarView, icon: ScanLine, label: 'Pending Devices' },
|
{ kind: 'hidden', icon: EyeOff, label: 'Hidden Devices' },
|
||||||
{ id: 'hidden' as SidebarView, icon: EyeOff, label: 'Hidden Devices' },
|
|
||||||
{ id: 'history' as SidebarView, icon: Clock, label: 'Scan History' },
|
|
||||||
]
|
]
|
||||||
const VIEWS = STANDALONE ? ALL_VIEWS.slice(0, 1) : ALL_VIEWS
|
|
||||||
|
|
||||||
interface ScanRun {
|
interface ScanRun {
|
||||||
id: string
|
id: string
|
||||||
status: string
|
status: string
|
||||||
|
kind?: string
|
||||||
ranges: string[]
|
ranges: string[]
|
||||||
devices_found: number
|
devices_found: number
|
||||||
started_at: string
|
started_at: string
|
||||||
@@ -34,25 +41,63 @@ interface ScanRun {
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
onAddNode: () => void
|
onAddNode: () => void
|
||||||
onAddGroupRect: () => void
|
onAddGroupRect: () => void
|
||||||
|
onAddText: () => void
|
||||||
onScan: () => void
|
onScan: () => void
|
||||||
|
onZigbeeImport: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onNodeApproved: (nodeId: string) => void
|
|
||||||
forceView?: SidebarView
|
forceView?: SidebarView
|
||||||
highlightPendingId?: string
|
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
|
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onSave, forceView, onOpenPending }: SidebarProps) {
|
||||||
const [_collapsed, setCollapsed] = useState(false)
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
const [_activeView, setActiveView] = useState<SidebarView>('canvas')
|
const [activeView, setActiveView] = useState<SidebarView>(forceView ?? 'canvas')
|
||||||
|
const [prevForceView, setPrevForceView] = useState(forceView)
|
||||||
const logout = useAuthStore((s) => s.logout)
|
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)
|
||||||
|
|
||||||
// When forceView is set, override local state without useEffect
|
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
|
||||||
const collapsed = forceView ? false : _collapsed
|
if (!designModal) return
|
||||||
const activeView = forceView ?? _activeView
|
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) {
|
||||||
|
setPrevForceView(forceView)
|
||||||
|
if (forceView) {
|
||||||
|
setActiveView(forceView)
|
||||||
|
setCollapsed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
const { nodes, hasUnsavedChanges, hideIp, toggleHideIp } = useCanvasStore()
|
||||||
|
|
||||||
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect')
|
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
|
||||||
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||||
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length
|
||||||
|
|
||||||
@@ -78,25 +123,107 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
<Logo size={28} showText={!collapsed} />
|
<Logo size={28} showText={!collapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Design Switcher */}
|
||||||
|
{!collapsed && designs.length > 0 && (
|
||||||
|
<div className="px-2 pt-2 pb-1 border-b border-border relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setDesignSwitcherOpen((o) => !o)}
|
||||||
|
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-xs font-medium bg-[#21262d] border border-border hover:border-[#30363d] transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{activeDesignId ? (() => {
|
||||||
|
const active = designs.find((d) => d.id === activeDesignId)
|
||||||
|
const Icon = 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 */}
|
{/* Views */}
|
||||||
<nav className="flex flex-col gap-0.5 p-2">
|
<nav className="flex flex-col gap-0.5 p-2">
|
||||||
{VIEWS.map(({ id, icon: Icon, label }) => (
|
<SidebarItem
|
||||||
|
icon={LayoutDashboard}
|
||||||
|
label="Canvas"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'canvas'}
|
||||||
|
onClick={() => setActiveView('canvas')}
|
||||||
|
/>
|
||||||
|
{!STANDALONE && PENDING_TRIGGERS.map((t) => (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
key={id}
|
key={t.kind}
|
||||||
icon={Icon}
|
icon={t.icon}
|
||||||
label={label}
|
label={t.label}
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
active={activeView === id}
|
onClick={() => onOpenPending(undefined, t.kind)}
|
||||||
onClick={() => setActiveView(id)}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SidebarItem
|
||||||
|
icon={Clock}
|
||||||
|
label="Scan History"
|
||||||
|
collapsed={collapsed}
|
||||||
|
active={activeView === 'history'}
|
||||||
|
onClick={() => setActiveView('history')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* View content (only when expanded) */}
|
{/* View content (only when expanded) */}
|
||||||
{!collapsed && activeView !== 'canvas' && (
|
{!collapsed && activeView !== 'canvas' && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
<div className="flex-1 min-h-0 overflow-y-auto border-t border-border">
|
||||||
{activeView === 'pending' && <PendingDevicesPanel onNodeApproved={onNodeApproved} highlightId={highlightPendingId} />}
|
|
||||||
{activeView === 'hidden' && <HiddenDevicesPanel />}
|
|
||||||
{activeView === 'history' && <ScanHistoryPanel />}
|
{activeView === 'history' && <ScanHistoryPanel />}
|
||||||
{activeView === 'settings' && <SettingsPanel />}
|
{activeView === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +256,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
<div className="flex flex-col gap-0.5 p-2 border-t border-border">
|
||||||
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
<SidebarItem icon={Plus} label="Add Node" collapsed={collapsed} onClick={onAddNode} />
|
||||||
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
<SidebarItem icon={Square} label="Add Zone" collapsed={collapsed} onClick={onAddGroupRect} />
|
||||||
|
<SidebarItem icon={Type} label="Add Text" collapsed={collapsed} onClick={onAddText} />
|
||||||
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
|
||||||
|
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
icon={hideIp ? EyeOff : Eye}
|
icon={hideIp ? EyeOff : Eye}
|
||||||
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
label={hideIp ? 'Show IPs' : 'Hide IPs'}
|
||||||
@@ -165,329 +294,22 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <VersionBadge />}
|
{!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>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
|
||||||
|
|
||||||
function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved: (nodeId: string) => void; highlightId?: string }) {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [selected, setSelected] = useState<PendingDevice | null>(null)
|
|
||||||
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set())
|
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
|
||||||
|
|
||||||
const allChecked = devices.length > 0 && checkedIds.size === devices.length
|
|
||||||
const someChecked = checkedIds.size > 0
|
|
||||||
|
|
||||||
const toggleCheck = (id: string, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setCheckedIds((prev) => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id); else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
setCheckedIds(allChecked ? new Set() : new Set(devices.map((d) => d.id)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.pending()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load pending devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleClearAll = async () => {
|
|
||||||
try {
|
|
||||||
await scanApi.clearPending()
|
|
||||||
setDevices([])
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success('Pending devices cleared')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to clear pending devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkApprove = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkApprove(ids)
|
|
||||||
const deviceToNode: Record<string, string> = {}
|
|
||||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
|
||||||
const approvedDevices = devices.filter((d) => ids.includes(d.id))
|
|
||||||
approvedDevices.forEach((d, i) => {
|
|
||||||
const nodeId = deviceToNode[d.id]
|
|
||||||
if (!nodeId) return
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
position: { x: 400 + (i % 4) * 160, y: 300 + Math.floor(i / 4) * 100 },
|
|
||||||
data: {
|
|
||||||
label: d.hostname ?? d.ip,
|
|
||||||
type: (d.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: d.ip,
|
|
||||||
hostname: d.hostname ?? undefined,
|
|
||||||
status: 'unknown' as const,
|
|
||||||
services: (d.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
})
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk approve devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBulkHide = async () => {
|
|
||||||
const ids = [...checkedIds]
|
|
||||||
try {
|
|
||||||
const res = await scanApi.bulkHide(ids)
|
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
|
||||||
setCheckedIds(new Set())
|
|
||||||
toast.success(`Hidden ${res.data.hidden} device${res.data.hidden !== 1 ? 's' : ''}`)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to bulk hide devices')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (scanEventTs > 0) load()
|
|
||||||
}, [scanEventTs, load])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!highlightId || loading) return
|
|
||||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
||||||
}, [highlightId, loading])
|
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
const nodeData = {
|
|
||||||
label: device.hostname ?? device.ip,
|
|
||||||
type: (device.suggested_type ?? 'generic') as import('@/types').NodeType,
|
|
||||||
ip: device.ip,
|
|
||||||
hostname: device.hostname ?? undefined,
|
|
||||||
status: 'unknown',
|
|
||||||
services: (device.services ?? []) as import('@/types').ServiceInfo[],
|
|
||||||
}
|
|
||||||
const res = await scanApi.approve(device.id, nodeData)
|
|
||||||
const nodeId = res.data.node_id
|
|
||||||
addNode({
|
|
||||||
id: nodeId,
|
|
||||||
type: nodeData.type,
|
|
||||||
position: { x: 400, y: 300 },
|
|
||||||
data: { ...nodeData, status: 'unknown' as const },
|
|
||||||
})
|
|
||||||
toast.success(`Approved ${nodeData.label}`)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
setSelected(null)
|
|
||||||
onNodeApproved(nodeId)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to approve device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHide = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.hide(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
toast.success('Device hidden')
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to hide device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleIgnore = async (device: PendingDevice) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(device.id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to ignore device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={allChecked}
|
|
||||||
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked }}
|
|
||||||
onChange={toggleAll}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
|
||||||
title="Select all"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Pending</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5" title="Refresh">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
{devices.length > 0 && (
|
|
||||||
<button onClick={handleClearAll} className="text-muted-foreground hover:text-[#f85149] p-0.5" title="Clear all pending">
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{someChecked && (
|
|
||||||
<div className="flex items-center gap-1 mb-2">
|
|
||||||
<button
|
|
||||||
onClick={handleBulkApprove}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Approve ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleBulkHide}
|
|
||||||
className="flex-1 text-[10px] py-1 px-2 rounded bg-[#8b949e]/20 text-[#8b949e] hover:bg-[#8b949e]/30 transition-colors font-medium"
|
|
||||||
>
|
|
||||||
Hide ({checkedIds.size})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No pending devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => {
|
|
||||||
const namedService = d.services.find((s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port))
|
|
||||||
const titleService = namedService
|
|
||||||
?? d.services.find((s) => s.port === 80)
|
|
||||||
?? d.services.find((s) => s.port === 443)
|
|
||||||
?? d.services.find((s) => s.port === 22)
|
|
||||||
const title = titleService?.service_name ?? d.hostname ?? d.ip
|
|
||||||
const showIpBelow = title !== d.ip
|
|
||||||
const hasSsh = d.services.some((s) => s.port === 22)
|
|
||||||
const hasHttp = d.services.some((s) => s.port === 80)
|
|
||||||
const hasHttps = d.services.some((s) => s.port === 443)
|
|
||||||
const otherCount = d.services.filter((s) => s.port !== 22 && s.port !== 80 && s.port !== 443).length
|
|
||||||
const virtualBadge = detectVirtualBadge(d.mac)
|
|
||||||
const sourceColor = d.discovery_source === 'mdns' ? '#a855f7' : '#8b949e'
|
|
||||||
const sourceLabel = d.discovery_source === 'mdns' ? 'mDNS' : d.discovery_source === 'arp' ? 'ARP' : null
|
|
||||||
const isHighlighted = d.id === highlightId
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={d.id}
|
|
||||||
ref={isHighlighted ? highlightRef : null}
|
|
||||||
onClick={() => setSelected(d)}
|
|
||||||
className={`w-full mb-1.5 p-2 rounded-md text-xs text-left transition-colors border ${isHighlighted ? 'bg-[#2d3748] border-[#e3b341]' : checkedIds.has(d.id) ? 'bg-[#21262d] border-[#00d4ff]/40' : 'bg-[#21262d] border-transparent hover:bg-[#30363d] hover:border-[#30363d]'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checkedIds.has(d.id)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
|
|
||||||
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="text-foreground truncate font-medium">{title}</span>
|
|
||||||
</div>
|
|
||||||
{showIpBelow && (
|
|
||||||
<div className="font-mono text-muted-foreground truncate pl-3 text-[10px] mt-0.5">{d.ip}</div>
|
|
||||||
)}
|
|
||||||
{(hasSsh || hasHttp || hasHttps || otherCount > 0 || virtualBadge || sourceLabel) && (
|
|
||||||
<div className="flex items-center gap-1 pl-3 mt-1.5 flex-wrap">
|
|
||||||
{sourceLabel && <ServiceBadge label={sourceLabel} color={sourceColor} />}
|
|
||||||
{virtualBadge && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<span><ServiceBadge label={virtualBadge.label} color="#ff6e00" /></span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{virtualBadge.title}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{hasSsh && <ServiceBadge label="SSH" color="#a855f7" />}
|
|
||||||
{hasHttp && <ServiceBadge label="HTTP" color="#00d4ff" />}
|
|
||||||
{hasHttps && <ServiceBadge label="HTTPS" color="#39d353" />}
|
|
||||||
{otherCount > 0 && <ServiceBadge label={`+${otherCount}`} color="#8b949e" />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PendingDeviceModal
|
|
||||||
device={selected}
|
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
onApprove={handleApprove}
|
|
||||||
onHide={handleHide}
|
|
||||||
onIgnore={handleIgnore}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HiddenDevicesPanel() {
|
|
||||||
const [devices, setDevices] = useState<PendingDevice[]>([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
try {
|
|
||||||
const res = await scanApi.hidden()
|
|
||||||
setDevices(res.data)
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to load hidden devices')
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const handleIgnore = async (id: string) => {
|
|
||||||
try {
|
|
||||||
await scanApi.ignore(id)
|
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== id))
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to remove device')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Hidden</span>
|
|
||||||
<button onClick={load} className="text-muted-foreground hover:text-foreground p-0.5">
|
|
||||||
<RefreshCw size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loading && <Loader2 size={14} className="animate-spin text-muted-foreground mx-auto my-4" />}
|
|
||||||
{!loading && devices.length === 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No hidden devices</p>
|
|
||||||
)}
|
|
||||||
{devices.map((d) => (
|
|
||||||
<div key={d.id} className="mb-2 p-2 rounded-md bg-[#21262d] text-xs">
|
|
||||||
<div className="font-mono text-foreground">{d.ip}</div>
|
|
||||||
{d.hostname && <div className="text-muted-foreground truncate">{d.hostname}</div>}
|
|
||||||
<div className="flex gap-1 mt-1.5">
|
|
||||||
<ActionButton icon={Trash2} label="Remove" color="red" onClick={() => handleIgnore(d.id)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScanHistoryPanel() {
|
function ScanHistoryPanel() {
|
||||||
const [runs, setRuns] = useState<ScanRun[]>([])
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
@@ -500,12 +322,19 @@ function ScanHistoryPanel() {
|
|||||||
const res = await scanApi.runs()
|
const res = await scanApi.runs()
|
||||||
const next: ScanRun[] = res.data
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
// Toast when a run transitions from running → error
|
// Surface transitions and refresh dependent UI
|
||||||
for (const run of next) {
|
for (const run of next) {
|
||||||
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
if (prev?.status === 'running' && run.status === 'error') {
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
}
|
}
|
||||||
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
|
if (run.kind === 'zigbee') {
|
||||||
|
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
// Notify pending modal/canvas to refresh
|
||||||
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevRunsRef.current = next
|
prevRunsRef.current = next
|
||||||
setRuns(next)
|
setRuns(next)
|
||||||
@@ -566,6 +395,14 @@ function ScanHistoryPanel() {
|
|||||||
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||||
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
<span className="font-mono text-foreground capitalize">{r.status}</span>
|
||||||
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
{r.status === 'running' && <Loader2 size={10} className="animate-spin text-[#e3b341]" />}
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={r.kind === 'zigbee'
|
||||||
|
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||||
|
: { background: '#a855f722', color: '#a855f7' }}
|
||||||
|
>
|
||||||
|
{r.kind === 'zigbee' ? 'ZIG' : 'IP'}
|
||||||
|
</span>
|
||||||
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
<span className="ml-auto text-muted-foreground font-mono">{r.devices_found} found</span>
|
||||||
{r.status === 'running' && (
|
{r.status === 'running' && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -593,7 +430,7 @@ function ScanHistoryPanel() {
|
|||||||
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
<div className="text-[#8b949e] text-[10px] font-mono truncate">{r.ranges.join(', ')}</div>
|
||||||
)}
|
)}
|
||||||
{r.error && (
|
{r.error && (
|
||||||
<div className="text-[#f85149] text-[10px] mt-1 leading-tight break-words whitespace-pre-wrap">
|
<div className="text-[#f85149] text-[10px] mt-1 leading-tight wrap-break-word whitespace-pre-wrap">
|
||||||
{r.error}
|
{r.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -606,6 +443,7 @@ function ScanHistoryPanel() {
|
|||||||
function SettingsPanel() {
|
function SettingsPanel() {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsApi.get()
|
settingsApi.get()
|
||||||
@@ -613,6 +451,14 @@ function SettingsPanel() {
|
|||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -654,6 +500,41 @@ function SettingsPanel() {
|
|||||||
>
|
>
|
||||||
{saving ? 'Saving…' : 'Save'}
|
{saving ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -686,55 +567,6 @@ function VersionBadge() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
|
||||||
'00:50:56': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'00:0c:29': { label: 'VMware', title: 'VMware Virtual Machine' },
|
|
||||||
'08:00:27': { label: 'VBox', title: 'VirtualBox Virtual Machine' },
|
|
||||||
'00:15:5d': { label: 'Hyper-V', title: 'Hyper-V Virtual Machine' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectVirtualBadge(mac: string | null) {
|
|
||||||
if (!mac) return null
|
|
||||||
return MAC_OUI[mac.toLowerCase().slice(0, 8)] ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
function ServiceBadge({ label, color }: { label: string; color: string }) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="px-1 py-0.5 rounded text-[9px] font-mono font-medium leading-none border"
|
|
||||||
style={{ color, borderColor: `${color}40`, backgroundColor: `${color}15` }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActionButtonProps {
|
|
||||||
icon: React.ElementType
|
|
||||||
label: string
|
|
||||||
color?: 'green' | 'red'
|
|
||||||
onClick: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({ icon: Icon, label, color, onClick }: ActionButtonProps) {
|
|
||||||
const colorClass =
|
|
||||||
color === 'green' ? 'text-[#39d353] hover:bg-[#39d353]/10' :
|
|
||||||
color === 'red' ? 'text-[#f85149] hover:bg-[#f85149]/10' :
|
|
||||||
'text-muted-foreground hover:text-foreground hover:bg-[#30363d]'
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger>
|
|
||||||
<button onClick={onClick} className={`p-1 rounded ${colorClass} transition-colors`}>
|
|
||||||
<Icon size={11} />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom">{label}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarItemProps {
|
interface SidebarItemProps {
|
||||||
icon: React.ElementType
|
icon: React.ElementType
|
||||||
label: string
|
label: string
|
||||||
@@ -749,7 +581,7 @@ function SidebarItem({ icon: Icon, label, collapsed, active, badge, accent, onCl
|
|||||||
const btn = (
|
const btn = (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors ${
|
className={`relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors cursor-pointer ${
|
||||||
active
|
active
|
||||||
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
? 'bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
: accent
|
: accent
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="ghost"
|
||||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={past.length === 0}
|
disabled={past.length === 0}
|
||||||
title="Undo (Ctrl+Z)"
|
title="Undo (Ctrl+Z)"
|
||||||
@@ -48,7 +48,7 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm" variant="ghost"
|
size="sm" variant="ghost"
|
||||||
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
className="gap-1.5 text-muted-foreground hover:text-foreground disabled:opacity-30 cursor-pointer hover:bg-[#21262d]"
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={future.length === 0}
|
disabled={future.length === 0}
|
||||||
title="Redo (Ctrl+Y)"
|
title="Redo (Ctrl+Y)"
|
||||||
@@ -56,13 +56,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Redo2 size={14} />
|
<Redo2 size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-border mx-1" />
|
<div className="w-px h-4 bg-border mx-1" />
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onAutoLayout}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onAutoLayout}>
|
||||||
<LayoutDashboard size={14} /> Auto Layout
|
<LayoutDashboard size={14} /> Auto Layout
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onChangeStyle}>
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onChangeStyle}>
|
||||||
<Palette size={14} /> Style
|
<Palette size={14} /> Style
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={() => fileInputRef.current?.click()} title="Import from YAML">
|
||||||
<Upload size={14} /> Import
|
<Upload size={14} /> Import
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
@@ -72,21 +72,21 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportYaml} title="Export canvas as YAML">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportYaml} title="Export canvas as YAML">
|
||||||
<Download size={14} /> Export
|
<Download size={14} /> Export
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExport} title="Download canvas as PNG">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExport} title="Download canvas as PNG">
|
||||||
<FileDown size={14} /> PNG
|
<FileDown size={14} /> PNG
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<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
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<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} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="gap-1.5 relative"
|
className="gap-1.5 relative cursor-pointer border border-transparent hover:border-white"
|
||||||
style={{
|
style={{
|
||||||
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
background: hasUnsavedChanges ? '#00d4ff' : undefined,
|
||||||
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
color: hasUnsavedChanges ? '#0d1117' : undefined,
|
||||||
|
|||||||
@@ -442,7 +442,23 @@ describe('DetailPanel', () => {
|
|||||||
it('displays full comma-separated IP string as link text', () => {
|
it('displays full comma-separated IP string as link text', () => {
|
||||||
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
setupStore({ ip: '192.168.1.10, 192.168.1.11' })
|
||||||
render(<DetailPanel onEdit={vi.fn()} />)
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -487,4 +503,27 @@ describe('DetailPanel', () => {
|
|||||||
expect(screen.getByText('health').tagName).not.toBe('A')
|
expect(screen.getByText('health').tagName).not.toBe('A')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('Last Seen formatting', () => {
|
||||||
|
it('renders a valid date when last_seen has +00:00 offset (no Z)', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38.221403+00:00' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a valid date when last_seen ends with Z', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38.221403Z' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats naive ISO strings as UTC', () => {
|
||||||
|
setupStore({ last_seen: '2026-05-10T17:54:38' })
|
||||||
|
render(<DetailPanel onEdit={vi.fn()} />)
|
||||||
|
const row = screen.getByText('Last Seen').parentElement
|
||||||
|
expect(row?.textContent).not.toMatch(/Invalid Date/)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,22 +11,11 @@ import type { NodeData } from '@/types'
|
|||||||
vi.mock('@/stores/canvasStore')
|
vi.mock('@/stores/canvasStore')
|
||||||
vi.mock('@/stores/authStore')
|
vi.mock('@/stores/authStore')
|
||||||
|
|
||||||
const mockBulkApprove = vi.fn()
|
|
||||||
const mockBulkHide = vi.fn()
|
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
trigger: vi.fn().mockResolvedValue({}),
|
trigger: vi.fn().mockResolvedValue({}),
|
||||||
pending: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
hidden: vi.fn().mockResolvedValue({ data: [] }),
|
|
||||||
runs: vi.fn().mockResolvedValue({ data: [] }),
|
runs: vi.fn().mockResolvedValue({ data: [] }),
|
||||||
stop: vi.fn().mockResolvedValue({}),
|
stop: vi.fn().mockResolvedValue({}),
|
||||||
clearPending: vi.fn().mockResolvedValue({}),
|
|
||||||
approve: vi.fn().mockResolvedValue({ data: { approved: true, node_id: 'new-node-1' } }),
|
|
||||||
hide: vi.fn().mockResolvedValue({ data: { hidden: true } }),
|
|
||||||
ignore: vi.fn().mockResolvedValue({ data: { ignored: true } }),
|
|
||||||
bulkApprove: (...args: unknown[]) => mockBulkApprove(...args),
|
|
||||||
bulkHide: (...args: unknown[]) => mockBulkHide(...args),
|
|
||||||
},
|
},
|
||||||
settingsApi: {
|
settingsApi: {
|
||||||
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
|
||||||
@@ -48,10 +37,6 @@ vi.mock('@/components/ui/tooltip', () => ({
|
|||||||
TooltipContent: () => null,
|
TooltipContent: () => null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
|
||||||
PendingDeviceModal: () => null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
const makeNode = (id: string, status: NodeData['status'], type: NodeData['type'] = 'server'): Node<NodeData> => ({
|
||||||
@@ -86,8 +71,9 @@ const defaultProps = {
|
|||||||
onAddNode: vi.fn(),
|
onAddNode: vi.fn(),
|
||||||
onAddGroupRect: vi.fn(),
|
onAddGroupRect: vi.fn(),
|
||||||
onScan: vi.fn(),
|
onScan: vi.fn(),
|
||||||
|
onZigbeeImport: vi.fn(),
|
||||||
onSave: vi.fn(),
|
onSave: vi.fn(),
|
||||||
onNodeApproved: vi.fn(),
|
onOpenPending: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -129,26 +115,22 @@ describe('Sidebar', () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total (excludes groupRect)
|
|
||||||
expect(screen.getByText('4')).toBeInTheDocument()
|
expect(screen.getByText('4')).toBeInTheDocument()
|
||||||
// Online
|
|
||||||
expect(screen.getByText('2')).toBeInTheDocument()
|
expect(screen.getByText('2')).toBeInTheDocument()
|
||||||
// Offline
|
|
||||||
expect(screen.getByText('1')).toBeInTheDocument()
|
expect(screen.getByText('1')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from stats', () => {
|
it('excludes groupRect nodes from stats', () => {
|
||||||
mockStore({
|
mockStore({
|
||||||
nodes: [
|
nodes: [
|
||||||
makeNode('n1', 'unknown'), // 1 real node, not online/offline
|
makeNode('n1', 'unknown'),
|
||||||
makeNode('zone', 'unknown', 'groupRect'),
|
makeNode('zone', 'unknown', 'groupRect'),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// Total row shows 1 (groupRect excluded), online/offline both 0
|
|
||||||
const totalRow = screen.getByText('Total').closest('div')!
|
const totalRow = screen.getByText('Total').closest('div')!
|
||||||
expect(totalRow).toHaveTextContent('1')
|
expect(totalRow).toHaveTextContent('1')
|
||||||
expect(screen.getAllByText('0')).toHaveLength(2) // online=0, offline=0
|
expect(screen.getAllByText('0')).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Collapse ───────────────────────────────────────────────────────────────
|
// ── Collapse ───────────────────────────────────────────────────────────────
|
||||||
@@ -225,7 +207,6 @@ describe('Sidebar', () => {
|
|||||||
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
it('shows unsaved badge dot on Save Canvas when hasUnsavedChanges', () => {
|
||||||
mockStore({ hasUnsavedChanges: true })
|
mockStore({ hasUnsavedChanges: true })
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
// The badge is a span sibling of the Save Canvas button icon
|
|
||||||
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
const saveBtn = screen.getByText('Save Canvas').closest('button')!
|
||||||
const badge = saveBtn.querySelector('span.rounded-full')
|
const badge = saveBtn.querySelector('span.rounded-full')
|
||||||
expect(badge).toBeInTheDocument()
|
expect(badge).toBeInTheDocument()
|
||||||
@@ -241,24 +222,24 @@ describe('Sidebar', () => {
|
|||||||
|
|
||||||
// ── Scan action ────────────────────────────────────────────────────────────
|
// ── Scan action ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('calls onScan prop when Scan Network is clicked (scan trigger moved to ScanConfigModal)', () => {
|
it('calls onScan prop when Scan Network is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Scan Network'))
|
fireEvent.click(screen.getByText('Scan Network'))
|
||||||
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
expect(defaultProps.onScan).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
// ── Pending / Hidden open modal ────────────────────────────────────────────
|
||||||
|
|
||||||
it('shows Pending panel when Pending Devices nav item is clicked', async () => {
|
it('calls onOpenPending with pending status when Pending Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Pending Devices'))
|
fireEvent.click(screen.getByText('Pending Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No pending devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'pending')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows Hidden panel when Hidden Devices nav item is clicked', async () => {
|
it('calls onOpenPending with hidden status when Hidden Devices is clicked', () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Hidden Devices'))
|
fireEvent.click(screen.getByText('Hidden Devices'))
|
||||||
await waitFor(() => expect(screen.getByText('No hidden devices')).toBeInTheDocument())
|
expect(defaultProps.onOpenPending).toHaveBeenCalledWith(undefined, 'hidden')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows History panel when Scan History nav item is clicked', async () => {
|
it('shows History panel when Scan History nav item is clicked', async () => {
|
||||||
@@ -267,13 +248,21 @@ describe('Sidebar', () => {
|
|||||||
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: forceView must not freeze local state across rerenders.
|
||||||
|
it('allows switching views after forceView is set by parent', async () => {
|
||||||
|
const { rerender } = render(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
await waitFor(() => expect(screen.getByText('No scans yet')).toBeInTheDocument())
|
||||||
|
rerender(<Sidebar {...defaultProps} forceView="history" />)
|
||||||
|
fireEvent.click(screen.getByText('Canvas'))
|
||||||
|
await waitFor(() => expect(screen.queryByText('No scans yet')).not.toBeInTheDocument())
|
||||||
|
})
|
||||||
|
|
||||||
it('toggles Settings panel on Settings click', async () => {
|
it('toggles Settings panel on Settings click', async () => {
|
||||||
render(<Sidebar {...defaultProps} />)
|
render(<Sidebar {...defaultProps} />)
|
||||||
fireEvent.click(screen.getByText('Settings'))
|
fireEvent.click(screen.getByText('Settings'))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
expect(screen.getByText('Status check interval (s)')).toBeInTheDocument(),
|
||||||
)
|
)
|
||||||
// Click the nav button again to close (use role to avoid matching the panel heading)
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||||
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -291,101 +280,3 @@ describe('Sidebar', () => {
|
|||||||
expect(mockLogout).toHaveBeenCalledOnce()
|
expect(mockLogout).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
|
|
||||||
|
|
||||||
const DEVICE_A = {
|
|
||||||
id: 'dev-a',
|
|
||||||
ip: '192.168.1.10',
|
|
||||||
hostname: 'host-a',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEVICE_B = {
|
|
||||||
id: 'dev-b',
|
|
||||||
ip: '192.168.1.11',
|
|
||||||
hostname: 'host-b',
|
|
||||||
mac: null,
|
|
||||||
os: null,
|
|
||||||
services: [],
|
|
||||||
suggested_type: 'generic',
|
|
||||||
status: 'pending',
|
|
||||||
discovery_source: 'arp',
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PendingDevicesPanel — bulk select', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockStore()
|
|
||||||
mockAuth()
|
|
||||||
vi.clearAllMocks()
|
|
||||||
mockBulkApprove.mockResolvedValue({
|
|
||||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },
|
|
||||||
})
|
|
||||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
|
||||||
})
|
|
||||||
|
|
||||||
async function renderWithDevices() {
|
|
||||||
const { scanApi } = await import('@/api/client')
|
|
||||||
vi.mocked(scanApi.pending).mockResolvedValue({ data: [DEVICE_A, DEVICE_B] } as never)
|
|
||||||
render(<Sidebar {...defaultProps} forceView="pending" />)
|
|
||||||
await waitFor(() => expect(screen.getByText('host-a')).toBeInTheDocument())
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders checkboxes for each device', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const checkboxes = screen.getAllByRole('checkbox')
|
|
||||||
// select-all + 2 device checkboxes
|
|
||||||
expect(checkboxes.length).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows bulk action bar when a device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [, firstDeviceCheckbox] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(firstDeviceCheckbox)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(1\)/)).toBeInTheDocument())
|
|
||||||
expect(screen.getByText(/Hide \(1\)/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides bulk action bar when no device is checked', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all checks all devices', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Approve \(2\)/)).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('select-all unchecks all when all are selected', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll) // select all
|
|
||||||
fireEvent.click(selectAll) // deselect all
|
|
||||||
await waitFor(() => expect(screen.queryByText(/Approve \(/)).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkApprove with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Approve \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-a')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls bulkHide with checked ids and removes devices from list', async () => {
|
|
||||||
await renderWithDevices()
|
|
||||||
const [selectAll] = screen.getAllByRole('checkbox')
|
|
||||||
fireEvent.click(selectAll)
|
|
||||||
fireEvent.click(screen.getByText(/Hide \(2\)/))
|
|
||||||
await waitFor(() => expect(mockBulkHide).toHaveBeenCalledWith(['dev-a', 'dev-b']))
|
|
||||||
await waitFor(() => expect(screen.queryByText('host-b')).not.toBeInTheDocument())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
import { resolveCustomIcon, brandIconUrl, isBrandIconKey } from '@/utils/nodeIcons'
|
||||||
|
|
||||||
|
interface NodeIconProps {
|
||||||
|
/** Default icon for the node type (lucide). Used when no customIconKey or unknown key. */
|
||||||
|
typeIcon: LucideIcon
|
||||||
|
/** Optional override key. Legacy lucide keys or `brand:<slug>` for dashboard-icons. */
|
||||||
|
customIconKey?: string
|
||||||
|
size?: number
|
||||||
|
className?: string
|
||||||
|
/** Optional inline color (lucide only — ignored for brand icons). */
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeIcon({ typeIcon, customIconKey, size = 16, className, color }: NodeIconProps) {
|
||||||
|
const resolved = resolveCustomIcon(customIconKey)
|
||||||
|
if (resolved?.kind === 'brand') {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={resolved.url}
|
||||||
|
alt={resolved.slug}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
loading="lazy"
|
||||||
|
className={className}
|
||||||
|
style={{ width: size, height: size, objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const Icon = resolved?.kind === 'lucide' ? resolved.icon : typeIcon
|
||||||
|
return createElement(Icon, { size, className, color })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { brandIconUrl, isBrandIconKey }
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import modalStyles from '../modals/modal-interactive.module.css'
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
@@ -63,13 +64,12 @@ function DialogContent({
|
|||||||
render={
|
render={
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="absolute top-2 right-2"
|
className={"absolute top-2 right-2 " + modalStyles['modal-close-pointer']}
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Network, Router, Cpu, CheckCircle2, XCircle, Loader2, Plus } from 'lucide-react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import type { ZigbeeNode, ZigbeeEdge } from './types'
|
||||||
|
|
||||||
|
interface ZigbeeImportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAddToCanvas: (nodes: ZigbeeNode[], edges: ZigbeeEdge[]) => void
|
||||||
|
onPendingImported?: (
|
||||||
|
coordinator?: { id: string; label: string; ieee_address: string } | null,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImportMode = 'pending' | 'canvas'
|
||||||
|
|
||||||
|
interface ConnectionForm {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: string
|
||||||
|
mqtt_username: string
|
||||||
|
mqtt_password: string
|
||||||
|
base_topic: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
mqtt_tls_insecure: boolean
|
||||||
|
port_user_edited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: ConnectionForm = {
|
||||||
|
mqtt_host: '',
|
||||||
|
mqtt_port: '1883',
|
||||||
|
mqtt_username: '',
|
||||||
|
mqtt_password: '',
|
||||||
|
base_topic: 'zigbee2mqtt',
|
||||||
|
mqtt_tls: false,
|
||||||
|
mqtt_tls_insecure: false,
|
||||||
|
port_user_edited: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEVICE_TYPE_ICON = {
|
||||||
|
zigbee_coordinator: Network,
|
||||||
|
zigbee_router: Router,
|
||||||
|
zigbee_enddevice: Cpu,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_LABEL = {
|
||||||
|
zigbee_coordinator: 'Coordinator',
|
||||||
|
zigbee_router: 'Router',
|
||||||
|
zigbee_enddevice: 'End Device',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const DEVICE_TYPE_COLOR = {
|
||||||
|
zigbee_coordinator: '#00d4ff',
|
||||||
|
zigbee_router: '#39d353',
|
||||||
|
zigbee_enddevice: '#e3b341',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export function ZigbeeImportModal({ open, onClose, onAddToCanvas, onPendingImported }: ZigbeeImportModalProps) {
|
||||||
|
const [form, setForm] = useState<ConnectionForm>(DEFAULT_FORM)
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>('idle')
|
||||||
|
const [connectionMsg, setConnectionMsg] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [devices, setDevices] = useState<ZigbeeNode[]>([])
|
||||||
|
const [edges, setEdges] = useState<ZigbeeEdge[]>([])
|
||||||
|
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||||
|
const [importMode, setImportMode] = useState<ImportMode>('pending')
|
||||||
|
|
||||||
|
const updateField = (field: keyof ConnectionForm, value: string) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
[field]: value,
|
||||||
|
...(field === 'mqtt_port' ? { port_user_edited: true } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toggleTls = (next: boolean) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const port = f.port_user_edited
|
||||||
|
? f.mqtt_port
|
||||||
|
: next
|
||||||
|
? '8883'
|
||||||
|
: '1883'
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
mqtt_tls: next,
|
||||||
|
mqtt_tls_insecure: next ? f.mqtt_tls_insecure : false,
|
||||||
|
mqtt_port: port,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildPayload = () => ({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
base_topic: form.base_topic.trim() || 'zigbee2mqtt',
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setConnectionStatus('testing')
|
||||||
|
try {
|
||||||
|
const res = await zigbeeApi.testConnection({
|
||||||
|
mqtt_host: form.mqtt_host.trim(),
|
||||||
|
mqtt_port: Number(form.mqtt_port) || (form.mqtt_tls ? 8883 : 1883),
|
||||||
|
mqtt_username: form.mqtt_username.trim() || undefined,
|
||||||
|
mqtt_password: form.mqtt_password || undefined,
|
||||||
|
mqtt_tls: form.mqtt_tls,
|
||||||
|
mqtt_tls_insecure: form.mqtt_tls_insecure,
|
||||||
|
})
|
||||||
|
if (res.data.connected) {
|
||||||
|
setConnectionStatus('ok')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg(res.data.message)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus('fail')
|
||||||
|
setConnectionMsg('Request failed — check broker address')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractError = (err: unknown): string | undefined => {
|
||||||
|
if (err && typeof err === 'object' && 'response' in err) {
|
||||||
|
return (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFetchDevices = async () => {
|
||||||
|
if (!form.mqtt_host.trim()) { toast.error('Enter a broker hostname'); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (importMode === 'pending') {
|
||||||
|
await zigbeeApi.importToPending(buildPayload())
|
||||||
|
toast.success('Zigbee import started — track progress in Scan History')
|
||||||
|
onPendingImported?.(null)
|
||||||
|
handleClose()
|
||||||
|
} else {
|
||||||
|
const res = await zigbeeApi.importNetwork(buildPayload())
|
||||||
|
setDevices(res.data.nodes)
|
||||||
|
setEdges(res.data.edges)
|
||||||
|
setChecked(new Set(res.data.nodes.map((n) => n.id)))
|
||||||
|
if (res.data.device_count === 0) {
|
||||||
|
toast.info('No Zigbee devices found in the network map')
|
||||||
|
} else {
|
||||||
|
toast.success(`Found ${res.data.device_count} device${res.data.device_count !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(extractError(err) ?? 'Failed to fetch Zigbee devices')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCheck = (id: string) =>
|
||||||
|
setChecked((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
setChecked(checked.size === devices.length ? new Set() : new Set(devices.map((d) => d.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddToCanvas = () => {
|
||||||
|
const selectedDevices = devices.filter((d) => checked.has(d.id))
|
||||||
|
const selectedIds = new Set(selectedDevices.map((d) => d.id))
|
||||||
|
const selectedEdges = edges.filter((e) => selectedIds.has(e.source) && selectedIds.has(e.target))
|
||||||
|
onAddToCanvas(selectedDevices, selectedEdges)
|
||||||
|
toast.success(`Added ${selectedDevices.length} device${selectedDevices.length !== 1 ? 's' : ''} to canvas`)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setDevices([])
|
||||||
|
setEdges([])
|
||||||
|
setChecked(new Set())
|
||||||
|
setConnectionStatus('idle')
|
||||||
|
setConnectionMsg('')
|
||||||
|
setImportMode('pending')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedDevices = {
|
||||||
|
zigbee_coordinator: devices.filter((d) => d.type === 'zigbee_coordinator'),
|
||||||
|
zigbee_router: devices.filter((d) => d.type === 'zigbee_router'),
|
||||||
|
zigbee_enddevice: devices.filter((d) => d.type === 'zigbee_enddevice'),
|
||||||
|
} as const
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-xl max-h-[85vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||||
|
<Network size={16} className="text-[#00d4ff]" />
|
||||||
|
Zigbee2MQTT Import
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-2 min-h-0">
|
||||||
|
{/* Connection Form */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="col-span-2 space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Broker Host</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_host}
|
||||||
|
onChange={(e) => updateField('mqtt_host', e.target.value)}
|
||||||
|
placeholder="192.168.1.x or mqtt.local"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Port</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_port}
|
||||||
|
onChange={(e) => updateField('mqtt_port', e.target.value)}
|
||||||
|
placeholder="1883"
|
||||||
|
type="number"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Base Topic</Label>
|
||||||
|
<Input
|
||||||
|
value={form.base_topic}
|
||||||
|
onChange={(e) => updateField('base_topic', e.target.value)}
|
||||||
|
placeholder="zigbee2mqtt"
|
||||||
|
className="font-mono text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Username (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_username}
|
||||||
|
onChange={(e) => updateField('mqtt_username', e.target.value)}
|
||||||
|
placeholder="mqtt_user"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Password (optional)</Label>
|
||||||
|
<Input
|
||||||
|
value={form.mqtt_password}
|
||||||
|
onChange={(e) => updateField('mqtt_password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="text-sm bg-[#0d1117] border-border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 flex items-center gap-4 pt-1">
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls}
|
||||||
|
onChange={(e) => toggleTls(e.target.checked)}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Use TLS (port 8883)
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-1.5 text-xs cursor-pointer ${
|
||||||
|
form.mqtt_tls ? 'text-[#f85149]' : 'text-muted-foreground/40 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.mqtt_tls_insecure}
|
||||||
|
disabled={!form.mqtt_tls}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, mqtt_tls_insecure: e.target.checked }))
|
||||||
|
}
|
||||||
|
className="w-3 h-3 accent-[#f85149] cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
Skip cert verify (self-signed only)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection status indicator */}
|
||||||
|
{connectionStatus !== 'idle' && (
|
||||||
|
<div className={`flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md border ${
|
||||||
|
connectionStatus === 'ok'
|
||||||
|
? 'bg-[#39d353]/10 border-[#39d353]/30 text-[#39d353]'
|
||||||
|
: connectionStatus === 'fail'
|
||||||
|
? 'bg-[#f85149]/10 border-[#f85149]/30 text-[#f85149]'
|
||||||
|
: 'bg-[#e3b341]/10 border-[#e3b341]/30 text-[#e3b341]'
|
||||||
|
}`}>
|
||||||
|
{connectionStatus === 'testing' && <Loader2 size={12} className="animate-spin" />}
|
||||||
|
{connectionStatus === 'ok' && <CheckCircle2 size={12} />}
|
||||||
|
{connectionStatus === 'fail' && <XCircle size={12} />}
|
||||||
|
<span>{connectionStatus === 'testing' ? 'Testing…' : connectionMsg}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<span className="text-muted-foreground">Send devices to:</span>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zigbee-import-mode"
|
||||||
|
checked={importMode === 'pending'}
|
||||||
|
onChange={() => setImportMode('pending')}
|
||||||
|
className="accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Pending section
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-foreground">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="zigbee-import-mode"
|
||||||
|
checked={importMode === 'canvas'}
|
||||||
|
onChange={() => setImportMode('canvas')}
|
||||||
|
className="accent-[#00d4ff] cursor-pointer"
|
||||||
|
/>
|
||||||
|
Canvas directly
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-1.5 text-muted-foreground hover:text-foreground border border-border hover:bg-[#21262d]"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={connectionStatus === 'testing' || loading}
|
||||||
|
>
|
||||||
|
{connectionStatus === 'testing'
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <CheckCircle2 size={13} />}
|
||||||
|
Test Connection
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
onClick={handleFetchDevices}
|
||||||
|
disabled={loading || connectionStatus === 'testing'}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 size={13} className="animate-spin" /> : <Network size={13} />}
|
||||||
|
{importMode === 'pending' ? 'Import to Pending' : 'Fetch Devices'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
|
Fetching the network map can take several minutes on large meshes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device List */}
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.size === devices.length}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = checked.size > 0 && checked.size < devices.length }}
|
||||||
|
onChange={toggleAll}
|
||||||
|
className="w-3 h-3 accent-[#00d4ff] cursor-pointer"
|
||||||
|
title="Select all"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Devices ({checked.size}/{devices.length} selected)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(Object.entries(groupedDevices) as [keyof typeof groupedDevices, ZigbeeNode[]][])
|
||||||
|
.filter(([, group]) => group.length > 0)
|
||||||
|
.map(([type, group]) => {
|
||||||
|
const Icon = DEVICE_TYPE_ICON[type]
|
||||||
|
const color = DEVICE_TYPE_COLOR[type]
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<Icon size={11} style={{ color }} />
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider" style={{ color }}>
|
||||||
|
{DEVICE_TYPE_LABEL[type]} ({group.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{group.map((device) => (
|
||||||
|
<div
|
||||||
|
key={device.id}
|
||||||
|
className={`flex items-start gap-2 p-2 mb-1 rounded-md text-xs cursor-pointer transition-colors border ${
|
||||||
|
checked.has(device.id)
|
||||||
|
? 'bg-[#21262d] border-[#00d4ff]/40'
|
||||||
|
: 'bg-[#21262d] border-transparent hover:bg-[#30363d]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleCheck(device.id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked.has(device.id)}
|
||||||
|
onChange={() => toggleCheck(device.id)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-3 h-3 mt-0.5 accent-[#00d4ff] cursor-pointer shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-foreground font-medium truncate">{device.friendly_name}</div>
|
||||||
|
<div className="font-mono text-[10px] text-muted-foreground truncate">{device.ieee_address}</div>
|
||||||
|
{(device.model || device.vendor) && (
|
||||||
|
<div className="text-[10px] text-muted-foreground truncate">
|
||||||
|
{[device.vendor, device.model].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{device.lqi != null && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded border shrink-0"
|
||||||
|
style={{ color: '#8b949e', borderColor: '#8b949e40' }}
|
||||||
|
>
|
||||||
|
LQI {device.lqi}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2 shrink-0 pt-2 border-t border-border">
|
||||||
|
<Button variant="ghost" onClick={handleClose}>Cancel</Button>
|
||||||
|
{devices.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleAddToCanvas}
|
||||||
|
disabled={checked.size === 0}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} />
|
||||||
|
Add {checked.size} to Canvas
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
import { ZigbeeImportModal } from '../ZigbeeImportModal'
|
||||||
|
|
||||||
|
vi.mock('@/api/client', () => ({
|
||||||
|
zigbeeApi: {
|
||||||
|
testConnection: vi.fn(),
|
||||||
|
importNetwork: vi.fn(),
|
||||||
|
importToPending: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
||||||
|
|
||||||
|
import { zigbeeApi } from '@/api/client'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
open: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
onAddToCanvas: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleNodes = [
|
||||||
|
{
|
||||||
|
id: '0x0000',
|
||||||
|
label: 'Coordinator',
|
||||||
|
type: 'zigbee_coordinator' as const,
|
||||||
|
ieee_address: '0x0000',
|
||||||
|
friendly_name: 'Coordinator',
|
||||||
|
device_type: 'Coordinator',
|
||||||
|
model: null,
|
||||||
|
vendor: null,
|
||||||
|
lqi: null,
|
||||||
|
parent_id: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '0x0001',
|
||||||
|
label: 'router_1',
|
||||||
|
type: 'zigbee_router' as const,
|
||||||
|
ieee_address: '0x0001',
|
||||||
|
friendly_name: 'router_1',
|
||||||
|
device_type: 'Router',
|
||||||
|
model: 'CC2530',
|
||||||
|
vendor: 'TI',
|
||||||
|
lqi: 200,
|
||||||
|
parent_id: '0x0000',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZigbeeImportModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockReset()
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockReset()
|
||||||
|
vi.mocked(zigbeeApi.importToPending).mockReset()
|
||||||
|
vi.mocked(toast.success).mockReset()
|
||||||
|
vi.mocked(toast.error).mockReset()
|
||||||
|
vi.mocked(toast.info).mockReset()
|
||||||
|
defaultProps.onClose.mockReset()
|
||||||
|
defaultProps.onAddToCanvas.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
const { container } = render(<ZigbeeImportModal {...defaultProps} open={false} />)
|
||||||
|
expect(container.querySelector('[role="dialog"]')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the modal with form fields when open', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
expect(screen.getByText('Zigbee2MQTT Import')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('192.168.1.x or mqtt.local')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('1883')).toBeDefined()
|
||||||
|
expect(screen.getByPlaceholderText('zigbee2mqtt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error toast when testing connection without a host', async () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Enter a broker hostname')
|
||||||
|
})
|
||||||
|
expect(zigbeeApi.testConnection).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows success status when connection test passes', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: true, message: 'Connection successful' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection successful')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows failure status when connection test fails', async () => {
|
||||||
|
vi.mocked(zigbeeApi.testConnection).mockResolvedValue({
|
||||||
|
data: { connected: false, message: 'Connection refused' },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '10.0.0.1' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /test connection/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Connection refused')).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectCanvasMode = () => {
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fetches devices and renders them grouped by type', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Coordinator')).toBeDefined()
|
||||||
|
expect(screen.getByText('router_1')).toBeDefined()
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('Found 2 devices')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows info toast when no devices found', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: [], edges: [], device_count: 0 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.info).toHaveBeenCalledWith('No Zigbee devices found in the network map')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onAddToCanvas with selected devices and closes modal', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [{ source: '0x0000', target: '0x0001' }], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
selectCanvasMode()
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByText('Coordinator'))
|
||||||
|
|
||||||
|
// Click "Add N to Canvas" button
|
||||||
|
const addBtn = screen.getByRole('button', { name: /add.*canvas/i })
|
||||||
|
fireEvent.click(addBtn)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(defaultProps.onAddToCanvas).toHaveBeenCalledOnce()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onClose when Cancel is clicked', () => {
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports to pending by default and notifies parent', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importToPending).mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
id: 'run-1',
|
||||||
|
status: 'running',
|
||||||
|
kind: 'zigbee',
|
||||||
|
ranges: ['192.168.1.100:1883'],
|
||||||
|
devices_found: 0,
|
||||||
|
started_at: '2026-01-01T00:00:00Z',
|
||||||
|
finished_at: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
const onPendingImported = vi.fn()
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} onPendingImported={onPendingImported} />)
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /import to pending/i }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(zigbeeApi.importToPending).toHaveBeenCalled()
|
||||||
|
expect(onPendingImported).toHaveBeenCalled()
|
||||||
|
expect(defaultProps.onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
expect(zigbeeApi.importNetwork).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('switching to canvas mode calls importNetwork and not importToPending', async () => {
|
||||||
|
vi.mocked(zigbeeApi.importNetwork).mockResolvedValue({
|
||||||
|
data: { nodes: sampleNodes, edges: [], device_count: 2 },
|
||||||
|
} as never)
|
||||||
|
|
||||||
|
render(<ZigbeeImportModal {...defaultProps} />)
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /canvas directly/i }))
|
||||||
|
const hostInput = screen.getByPlaceholderText('192.168.1.x or mqtt.local')
|
||||||
|
fireEvent.change(hostInput, { target: { value: '192.168.1.100' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /fetch devices/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(zigbeeApi.importNetwork).toHaveBeenCalled())
|
||||||
|
expect(zigbeeApi.importToPending).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Shared Zigbee type definitions for the frontend. */
|
||||||
|
|
||||||
|
export interface ZigbeeNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: 'zigbee_coordinator' | 'zigbee_router' | 'zigbee_enddevice'
|
||||||
|
ieee_address: string
|
||||||
|
friendly_name: string
|
||||||
|
device_type: string
|
||||||
|
model?: string | null
|
||||||
|
vendor?: string | null
|
||||||
|
lqi?: number | null
|
||||||
|
parent_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeEdge {
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeImportResponse {
|
||||||
|
nodes: ZigbeeNode[]
|
||||||
|
edges: ZigbeeEdge[]
|
||||||
|
device_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionRequest {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
mqtt_username?: string
|
||||||
|
mqtt_password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeTestConnectionResponse {
|
||||||
|
connected: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user