Compare commits
247 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96bb048228 | |||
| 88c006764a | |||
| 9c3dace12f | |||
| ec53363fdb | |||
| 0e260ede73 | |||
| 60902167c0 | |||
| 592e7865c5 | |||
| f082c295fd | |||
| 10b981ad1d | |||
| 2058e453ff | |||
| b776b6b8f1 | |||
| fe3787f0cd | |||
| 7ea1fc2d69 | |||
| 71cc183efb | |||
| 7873f8b94f | |||
| b34b40794d | |||
| ed310f0d8d | |||
| 2db173cd56 | |||
| d032103b7d | |||
| bcc488993d | |||
| aa0a97c245 | |||
| a39d4d4406 | |||
| 7c2417f5a9 | |||
| ea66e6c9c7 | |||
| f78bac6e6b | |||
| a0bfb97517 | |||
| ff9c4735e0 | |||
| 2f3e986d4a | |||
| 0a4bac30ac | |||
| ba2f6564ec | |||
| 754a79d21f | |||
| b52bbc6d9f | |||
| c67b1775a5 | |||
| 5940e11860 | |||
| fa8f949b45 | |||
| 6e17a2afc6 | |||
| 2375efa6b7 | |||
| 61fbebb13e | |||
| 05fb67e7f0 | |||
| 1a426f6301 | |||
| f56dfea838 | |||
| 6784b8f6be | |||
| 01fce64b2c | |||
| 79093fdcfb | |||
| 4ce8a12720 | |||
| cc2cc72482 | |||
| 12d527aad6 | |||
| 73b479a408 | |||
| 16183285b5 | |||
| 3b3cc1eba0 | |||
| 3da5517996 | |||
| cb26da3de5 | |||
| 23a0a47a7f | |||
| aac6c09a04 | |||
| bf90d6312b | |||
| ec15c260e1 | |||
| 47ab3a9a76 | |||
| eb7b0c6d38 | |||
| be320c2ac4 | |||
| 60ce648a96 | |||
| d5e35a885d | |||
| 3a57d809a4 | |||
| 46435605eb | |||
| e8bcf04b46 | |||
| 4ba04660c8 | |||
| 4b06ce6ef6 | |||
| 6761f73c17 | |||
| 1431f5b19e | |||
| b5628e18fa | |||
| dc70805673 | |||
| 05c24d622b | |||
| 541e25327b | |||
| 9823be9d78 | |||
| b71c96897a | |||
| 20e1820a4e | |||
| 517486ff79 | |||
| b5b1056ae6 | |||
| 66a9a57861 | |||
| 529c75a175 | |||
| fc765fa255 | |||
| 77159ce1cd | |||
| 69aa8256f0 | |||
| 78b43a300f | |||
| 995de26591 | |||
| 525dfe5ece | |||
| d066f37e88 | |||
| 004623bae5 | |||
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 | |||
| 1790d24ee7 | |||
| 8b97f578c6 | |||
| 48e7b3c690 | |||
| 3a4df578ec | |||
| 43426e96ac | |||
| 280d5a2ae1 | |||
| cdc6091bd0 | |||
| 14597ab86f | |||
| 14719a5c39 | |||
| ea451885af | |||
| 9cd93ef294 | |||
| 8d937b2a0d | |||
| 60ea0fa11b | |||
| ff0d33128c | |||
| 17d4ddd070 | |||
| 4795dc40b8 | |||
| 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']
|
||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke-and-integration:
|
smoke-and-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-scripts:
|
lint-scripts:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * 1' # Weekly on Monday
|
- cron: '0 9 * * 1' # Weekly on Monday
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secrets-scan:
|
secrets-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ htmlcov/
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db.back
|
*.db.back
|
||||||
|
*.db.back-*
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -2,12 +2,14 @@ import hmac
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
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.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
|
||||||
@@ -15,9 +17,30 @@ from app.schemas.nodes import NodeResponse
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class LiveViewConfigResponse(BaseModel):
|
||||||
|
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
|
||||||
|
|
||||||
|
enabled: bool
|
||||||
|
key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=LiveViewConfigResponse)
|
||||||
|
async def liveview_config(
|
||||||
|
_: str = Depends(get_current_user),
|
||||||
|
) -> LiveViewConfigResponse:
|
||||||
|
"""Authenticated: expose the configured live view key so the UI can build a
|
||||||
|
ready-to-use share link (e.g. /view?key=...&design=<id>).
|
||||||
|
|
||||||
|
Only reachable by a logged-in user — the key is never exposed publicly.
|
||||||
|
"""
|
||||||
|
key = settings.liveview_key or None
|
||||||
|
return LiveViewConfigResponse(enabled=bool(key), key=key)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
@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 +53,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,41 @@ 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"}
|
||||||
|
|
||||||
|
|
||||||
|
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list carrying a device MAC address.
|
||||||
|
|
||||||
|
Shape matches the frontend ``NodeProperty`` type
|
||||||
|
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
||||||
|
showing it on the canvas card from the right panel. Returns an empty list
|
||||||
|
when no MAC is known.
|
||||||
|
"""
|
||||||
|
if not mac:
|
||||||
|
return []
|
||||||
|
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def merge_mac_property(
|
||||||
|
props: list[dict[str, Any]] | None, mac: str | None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
||||||
|
|
||||||
|
Preserves any user-supplied properties (and an existing MAC row's
|
||||||
|
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
||||||
|
"""
|
||||||
|
out = [dict(p) for p in (props or [])]
|
||||||
|
if not mac or any(p.get("key") == "MAC" for p in out):
|
||||||
|
return out
|
||||||
|
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
class BulkActionRequest(BaseModel):
|
||||||
@@ -115,6 +146,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 +160,42 @@ 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,
|
||||||
|
mac=device.mac,
|
||||||
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 build_mac_property(device.mac),
|
||||||
|
# Default to ping so the status checker actually polls the new node.
|
||||||
|
# Without this the scheduler skips it (check_method NULL → no check).
|
||||||
|
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||||
|
design_id=default_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
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 +219,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 +261,138 @@ 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
|
||||||
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
|
# fall back to whatever the approve payload carried.
|
||||||
|
_mac = device.mac or node_data.mac
|
||||||
node = Node(
|
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,
|
||||||
|
mac=_mac,
|
||||||
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 merge_mac_property(node_data.properties, _mac),
|
||||||
|
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||||
|
check_target=None if _is_zigbee else node_data.check_target,
|
||||||
|
design_id=node_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
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")
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
"""App-level settings (status checker interval, etc.)."""
|
"""App-level settings (status checker interval, etc.)."""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(BaseModel):
|
class AppSettings(BaseModel):
|
||||||
interval_seconds: int
|
interval_seconds: int
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = Field(default=300, ge=30)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
@router.get("", response_model=AppSettings)
|
||||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
return AppSettings(
|
||||||
|
interval_seconds=settings.status_checker_interval,
|
||||||
|
service_check_enabled=settings.service_check_enabled,
|
||||||
|
service_check_interval=settings.service_check_interval,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AppSettings)
|
@router.post("", response_model=AppSettings)
|
||||||
@@ -23,7 +30,13 @@ async def update_settings(
|
|||||||
) -> AppSettings:
|
) -> AppSettings:
|
||||||
try:
|
try:
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
|
settings.service_check_enabled = payload.service_check_enabled
|
||||||
|
settings.service_check_interval = payload.service_check_interval
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
|
# Apply the service-check schedule live.
|
||||||
|
set_service_checks_enabled(payload.service_check_enabled)
|
||||||
|
if payload.service_check_enabled:
|
||||||
|
reschedule_service_checks(payload.service_check_interval)
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
@@ -10,6 +11,12 @@ router = APIRouter()
|
|||||||
_connections: list[WebSocket] = []
|
_connections: list[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
|
def _drop(websocket: WebSocket) -> None:
|
||||||
|
"""Remove a connection if still present — idempotent, never raises."""
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
_connections.remove(websocket)
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
# Accept first so we can send a close frame with a reason code
|
# Accept first so we can send a close frame with a reason code
|
||||||
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
|
|||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
_connections.remove(websocket)
|
pass
|
||||||
|
finally:
|
||||||
|
# Any error (disconnect or otherwise) must release the slot, else the
|
||||||
|
# dead socket lingers in the broadcast pool.
|
||||||
|
_drop(websocket)
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(payload: str) -> None:
|
async def _broadcast(payload: str) -> None:
|
||||||
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await conn.send_text(payload)
|
await conn.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
_connections.remove(conn)
|
_drop(conn)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||||
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||||
|
await _broadcast(json.dumps({
|
||||||
|
"type": "service_status",
|
||||||
|
"node_id": node_id,
|
||||||
|
"services": services,
|
||||||
|
"checked_at": checked_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||||
await _broadcast(json.dumps({
|
await _broadcast(json.dumps({
|
||||||
"type": "scan_device_found",
|
"type": "scan_device_found",
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
"""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 == "approved":
|
||||||
|
# The device was approved earlier but its canvas Node no longer
|
||||||
|
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
||||||
|
# the row to "pending" so it reappears in the Pending list on
|
||||||
|
# re-import instead of being silently swallowed. (Issue #167)
|
||||||
|
pending.status = "pending"
|
||||||
|
elif pending.status == "hidden":
|
||||||
|
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
||||||
|
pass
|
||||||
|
pending_updated += 1
|
||||||
|
|
||||||
|
# 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")
|
||||||
@@ -51,6 +51,10 @@ class Settings(BaseSettings):
|
|||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
|
# Per-service status checker (independent of node checks). Off by default.
|
||||||
|
service_check_enabled: bool = False
|
||||||
|
service_check_interval: int = 300
|
||||||
|
|
||||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
# Used by the MCP server to authenticate against the backend without a user password.
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
@@ -61,6 +65,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"
|
||||||
|
|
||||||
@@ -72,6 +81,10 @@ class Settings(BaseSettings):
|
|||||||
self.scanner_ranges = data["scanner_ranges"]
|
self.scanner_ranges = data["scanner_ranges"]
|
||||||
if "status_checker_interval" in data:
|
if "status_checker_interval" in data:
|
||||||
self.status_checker_interval = int(data["status_checker_interval"])
|
self.status_checker_interval = int(data["status_checker_interval"])
|
||||||
|
if "service_check_enabled" in data:
|
||||||
|
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||||
|
if "service_check_interval" in data:
|
||||||
|
self.service_check_interval = int(data["service_check_interval"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -81,6 +94,8 @@ class Settings(BaseSettings):
|
|||||||
self._override_path().write_text(json.dumps({
|
self._override_path().write_text(json.dumps({
|
||||||
"scanner_ranges": self.scanner_ranges,
|
"scanner_ranges": self.scanner_ranges,
|
||||||
"status_checker_interval": self.status_checker_interval,
|
"status_checker_interval": self.status_checker_interval,
|
||||||
|
"service_check_enabled": self.service_check_enabled,
|
||||||
|
"service_check_interval": self.service_check_interval,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal
|
from app.db.database import AsyncSessionLocal
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
from app.services.status_checker import check_node
|
from app.services.status_checker import check_node, check_services
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||||
|
"""Pick the address to probe services on: first IP, else hostname."""
|
||||||
|
if ip:
|
||||||
|
first = ip.split(",")[0].strip()
|
||||||
|
if first:
|
||||||
|
return first
|
||||||
|
return hostname or None
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_service_checks() -> None:
|
||||||
|
"""Check every service of every node and broadcast per-service results."""
|
||||||
|
if not settings.service_check_enabled:
|
||||||
|
return
|
||||||
|
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
checkable = [
|
||||||
|
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||||
|
for n in nodes
|
||||||
|
if n.services
|
||||||
|
]
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
for node_id, host, services in checkable:
|
||||||
|
try:
|
||||||
|
statuses = await check_services(host, services)
|
||||||
|
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_service_check_job() -> None:
|
||||||
|
scheduler.add_job(
|
||||||
|
_run_service_checks,
|
||||||
|
"interval",
|
||||||
|
seconds=settings.service_check_interval,
|
||||||
|
id="service_checks",
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
|||||||
max_instances=1,
|
max_instances=1,
|
||||||
coalesce=True,
|
coalesce=True,
|
||||||
)
|
)
|
||||||
|
if settings.service_check_enabled:
|
||||||
|
_add_service_check_job()
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
|||||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||||
|
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||||
|
if interval_seconds < 30:
|
||||||
|
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||||
|
if not scheduler.running:
|
||||||
|
logger.warning("Scheduler not running, skipping reschedule")
|
||||||
|
return
|
||||||
|
if scheduler.get_job("service_checks"):
|
||||||
|
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||||
|
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def set_service_checks_enabled(enabled: bool) -> None:
|
||||||
|
"""Add or remove the service-check job on the running scheduler."""
|
||||||
|
if not scheduler.running:
|
||||||
|
return
|
||||||
|
job = scheduler.get_job("service_checks")
|
||||||
|
if enabled and not job:
|
||||||
|
_add_service_check_job()
|
||||||
|
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||||
|
elif not enabled and job:
|
||||||
|
scheduler.remove_job("service_checks")
|
||||||
|
logger.info("Service checks disabled")
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
if scheduler.running:
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+190
-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)
|
||||||
|
|
||||||
@@ -72,6 +91,8 @@ async def init_db() -> None:
|
|||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||||
|
with suppress(OperationalError):
|
||||||
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
@@ -80,6 +101,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 +277,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 +289,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)
|
||||||
@@ -42,10 +54,12 @@ class Node(Base):
|
|||||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
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 +74,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 +91,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 +101,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 +109,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 +142,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")
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class NodeSave(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
show_port_numbers: bool = False
|
||||||
properties: list[Any] = []
|
properties: list[Any] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
@@ -62,9 +63,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):
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class NodeBase(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
|
show_port_numbers: bool = False
|
||||||
properties: list[dict[str, Any]] = []
|
properties: list[dict[str, Any]] = []
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
@@ -34,7 +35,7 @@ class NodeBase(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
pass
|
design_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdate(BaseModel):
|
class NodeUpdate(BaseModel):
|
||||||
@@ -60,6 +61,7 @@ class NodeUpdate(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
|
show_port_numbers: bool | None = None
|
||||||
properties: list[dict[str, Any]] | None = None
|
properties: list[dict[str, Any]] | None = None
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
@@ -68,6 +70,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:
|
||||||
@@ -59,11 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
return {"status": "offline", "response_time_ms": None}
|
return {"status": "offline", "response_time_ms": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ipv6(host: str) -> bool:
|
||||||
|
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||||
|
try:
|
||||||
|
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
|
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||||
|
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||||
|
#
|
||||||
|
# -W flag units differ by OS:
|
||||||
|
# Linux: seconds (-W 2 = 2s)
|
||||||
|
# macOS: milliseconds (-W 2000 = 2s)
|
||||||
|
# Windows: -w in ms (-w 2000 = 2s)
|
||||||
|
#
|
||||||
|
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||||
|
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||||
|
ipv6 = _is_ipv6(host)
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
family = ["-6"] if ipv6 else ["-4"]
|
||||||
|
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||||
else:
|
else:
|
||||||
args = ["ping", "-c", "1", "-W", "1", host]
|
family = ["-6"] if ipv6 else []
|
||||||
|
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
@@ -89,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
except (TimeoutError, OSError, socket.gaierror):
|
except (TimeoutError, OSError, socket.gaierror):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Per-service status checks ---
|
||||||
|
|
||||||
|
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||||
|
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||||
|
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||||
|
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||||
|
_NON_HTTP_PORTS = frozenset({
|
||||||
|
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||||
|
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||||
|
})
|
||||||
|
_HTTPS_PORTS = frozenset({443, 8443})
|
||||||
|
|
||||||
|
|
||||||
|
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||||
|
"""Bracket bare IPv6 literals for use in a URL."""
|
||||||
|
return f"[{host}]" if _is_ipv6(host) else host
|
||||||
|
|
||||||
|
|
||||||
|
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||||
|
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||||
|
|
||||||
|
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||||
|
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||||
|
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||||
|
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||||
|
"""
|
||||||
|
if not host or host.startswith("-"):
|
||||||
|
return "unknown"
|
||||||
|
if str(svc.get("protocol", "")).lower() == "udp":
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
port = svc.get("port")
|
||||||
|
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||||
|
|
||||||
|
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||||
|
if port is not None and port in _NON_HTTP_PORTS:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
name = str(svc.get("service_name", "")).lower()
|
||||||
|
is_web = port is not None or "http" in name
|
||||||
|
if not is_web:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
scheme = "https" if (
|
||||||
|
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||||
|
) else "http"
|
||||||
|
url_host = _service_host(svc, host)
|
||||||
|
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||||
|
return "online" if await _http_get(url, verify=False) else "offline"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||||
|
return "offline"
|
||||||
|
|
||||||
|
|
||||||
|
async def check_services(
|
||||||
|
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Check every service against host concurrently (bounded).
|
||||||
|
|
||||||
|
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||||
|
"""
|
||||||
|
sem = asyncio.Semaphore(concurrency)
|
||||||
|
|
||||||
|
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with sem:
|
||||||
|
status = await check_service(svc, host)
|
||||||
|
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||||
|
|
||||||
|
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||||
|
|||||||
@@ -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.12
|
||||||
|
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
|
||||||
|
|||||||
@@ -199,6 +199,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
|||||||
assert canvas["nodes"][0]["show_hardware"] is False
|
assert canvas["nodes"][0]["show_hardware"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
||||||
|
# toggle reset on every reload.
|
||||||
|
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload(show_port_numbers=True)
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_port_numbers"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
||||||
|
n1 = node_payload()
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
assert canvas["nodes"][0]["show_port_numbers"] is False
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
@@ -557,3 +575,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
|
||||||
@@ -124,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
|||||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||||
assert res.status_code == 403
|
assert res.status_code == 403
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
assert res.json()["detail"] == "Live view is disabled"
|
||||||
|
|
||||||
|
|
||||||
|
# ── /config (authenticated) — key used to build share links ──────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_requires_auth(client: AsyncClient):
|
||||||
|
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
|
||||||
|
settings.liveview_key = "secret"
|
||||||
|
res = await client.get("/api/v1/liveview/config")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = "share-me"
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body == {"enabled": True, "key": "share-me"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = None
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
|
||||||
|
settings.liveview_key = ""
|
||||||
|
headers = await auth_headers()
|
||||||
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
|
|
||||||
|
|
||||||
|
# ── design_id selects which canvas is rendered ───────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
|
||||||
|
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||||
|
settings.liveview_key = "test-key"
|
||||||
|
headers = await auth_headers()
|
||||||
|
|
||||||
|
# Create two designs
|
||||||
|
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
||||||
|
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
|
||||||
|
|
||||||
|
# Save a distinct node into each design
|
||||||
|
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
|
||||||
|
payload = {
|
||||||
|
"nodes": [{
|
||||||
|
"id": node_id,
|
||||||
|
"type": "server",
|
||||||
|
"label": label,
|
||||||
|
"status": "online",
|
||||||
|
"services": [],
|
||||||
|
"pos_x": 0,
|
||||||
|
"pos_y": 0,
|
||||||
|
}],
|
||||||
|
"edges": [],
|
||||||
|
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||||
|
"design_id": design["id"],
|
||||||
|
}
|
||||||
|
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||||
|
|
||||||
|
# Requesting d2 returns only the electrical node
|
||||||
|
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = res.json()["nodes"]
|
||||||
|
assert [n["id"] for n in nodes] == ["n-elec"]
|
||||||
|
|
||||||
|
# Requesting d1 returns only the network node
|
||||||
|
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
|
||||||
|
assert res.status_code == 200
|
||||||
|
nodes = res.json()["nodes"]
|
||||||
|
assert [n["id"] for n in nodes] == ["n-net"]
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Backward-compatibility tests for the legacy → multi-design migration.
|
||||||
|
|
||||||
|
Simulates a database created by a pre-"designs" version of the app and asserts
|
||||||
|
that running init_db() adopts all existing nodes/edges/canvas into a single
|
||||||
|
default "Network Topology" design with no data loss. The rest of the test suite
|
||||||
|
builds the *current* schema via create_all and never exercises this upgrade
|
||||||
|
path, so this file guards real users upgrading in place.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
import app.db.database as database
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def legacy_engine(tmp_path, monkeypatch):
|
||||||
|
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
|
||||||
|
db_path = tmp_path / "legacy.db"
|
||||||
|
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||||
|
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
monkeypatch.setattr(database, "engine", engine)
|
||||||
|
return db_path, engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_legacy_schema(engine) -> None:
|
||||||
|
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
|
||||||
|
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
|
||||||
|
"custom_style JSON, saved_at DATETIME)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||||
|
"VALUES ('n1','server','Old Server','online','[]',10,20)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
||||||
|
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
|
||||||
|
)
|
||||||
|
await conn.exec_driver_sql(
|
||||||
|
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
|
||||||
|
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
||||||
|
db_path, engine = legacy_engine
|
||||||
|
await _build_legacy_schema(engine)
|
||||||
|
|
||||||
|
await database.init_db()
|
||||||
|
|
||||||
|
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
async with check.begin() as conn:
|
||||||
|
# Exactly one seeded default design.
|
||||||
|
designs = (await conn.exec_driver_sql(
|
||||||
|
"SELECT id, name, design_type, icon FROM designs"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(designs) == 1
|
||||||
|
did, name, dtype, icon = designs[0]
|
||||||
|
assert name == "Network Topology"
|
||||||
|
assert dtype == "network"
|
||||||
|
assert icon == "dashboard"
|
||||||
|
|
||||||
|
# Every legacy node adopted into the default design, data preserved.
|
||||||
|
nodes = (await conn.exec_driver_sql(
|
||||||
|
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
|
||||||
|
)).fetchall()
|
||||||
|
assert [(n[0], n[1], n[2]) for n in nodes] == [
|
||||||
|
("n1", "Old Server", "online"),
|
||||||
|
("n2", "Old Router", "offline"),
|
||||||
|
]
|
||||||
|
assert all(n[3] == did for n in nodes)
|
||||||
|
|
||||||
|
# Legacy edge adopted too.
|
||||||
|
edge = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id FROM edges WHERE id='e1'"
|
||||||
|
)).fetchone()
|
||||||
|
assert edge[0] == did
|
||||||
|
|
||||||
|
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
|
||||||
|
# default design and the viewport survives.
|
||||||
|
cs = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id, viewport FROM canvas_state"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(cs) == 1
|
||||||
|
assert cs[0][0] == did
|
||||||
|
assert "zoom" in (cs[0][1] or "")
|
||||||
|
finally:
|
||||||
|
await check.dispose()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_migration_is_idempotent(legacy_engine):
|
||||||
|
"""Running init_db twice must not duplicate the design or drop any data."""
|
||||||
|
db_path, engine = legacy_engine
|
||||||
|
await _build_legacy_schema(engine)
|
||||||
|
|
||||||
|
await database.init_db()
|
||||||
|
await database.init_db() # second boot — should be a no-op
|
||||||
|
|
||||||
|
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||||
|
try:
|
||||||
|
async with check.begin() as conn:
|
||||||
|
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
|
||||||
|
assert len(designs) == 1
|
||||||
|
did = designs[0][0]
|
||||||
|
|
||||||
|
nodes = (await conn.exec_driver_sql(
|
||||||
|
"SELECT design_id FROM nodes"
|
||||||
|
)).fetchall()
|
||||||
|
assert len(nodes) == 2
|
||||||
|
assert all(n[0] == did for n in nodes)
|
||||||
|
|
||||||
|
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
|
||||||
|
assert len(cs) == 1
|
||||||
|
assert cs[0][0] == did
|
||||||
|
finally:
|
||||||
|
await check.dispose()
|
||||||
|
await engine.dispose()
|
||||||
@@ -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,256 @@ 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"
|
||||||
|
|
||||||
|
|
||||||
|
# --- MAC address propagation on approve (issue #168) ---
|
||||||
|
|
||||||
|
def test_build_mac_property_returns_hidden_row():
|
||||||
|
from app.api.routes.scan import build_mac_property
|
||||||
|
|
||||||
|
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_mac_property_empty_when_no_mac():
|
||||||
|
from app.api.routes.scan import build_mac_property
|
||||||
|
|
||||||
|
assert build_mac_property(None) == []
|
||||||
|
assert build_mac_property("") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_appends_when_absent():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||||
|
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||||
|
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
|
||||||
|
# Existing prop preserved untouched.
|
||||||
|
assert existing[0] in merged
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_idempotent_and_preserves_visibility():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
|
||||||
|
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||||
|
# No duplicate MAC row; user's visible=True choice kept.
|
||||||
|
macs = [p for p in merged if p["key"] == "MAC"]
|
||||||
|
assert len(macs) == 1
|
||||||
|
assert macs[0]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_merge_mac_property_noop_without_mac():
|
||||||
|
from app.api.routes.scan import merge_mac_property
|
||||||
|
|
||||||
|
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||||
|
assert merge_mac_property(existing, None) == existing
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_copies_mac_to_node_and_properties(
|
||||||
|
client: AsyncClient, headers, pending_device, db_session
|
||||||
|
):
|
||||||
|
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
# Payload intentionally omits mac — it must come from the pending device.
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||||
|
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert mac_props == [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device_does_not_duplicate_mac_property(
|
||||||
|
client: AsyncClient, headers, pending_device, db_session
|
||||||
|
):
|
||||||
|
"""If the approve payload already carries a MAC prop, don't add a second one."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||||
|
json={
|
||||||
|
"label": "My Server",
|
||||||
|
"type": "server",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
"properties": [
|
||||||
|
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||||
|
).scalar_one()
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert len(mac_props) == 1
|
||||||
|
# User's visibility choice is preserved.
|
||||||
|
assert mac_props[0]["visible"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||||
|
client: AsyncClient, headers, db_session
|
||||||
|
):
|
||||||
|
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.db.models import Node as NodeModel
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.55",
|
||||||
|
mac="11:22:33:44:55:66",
|
||||||
|
hostname="host-mac",
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/bulk-approve",
|
||||||
|
json={"device_ids": [device.id]},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
node = (
|
||||||
|
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
||||||
|
).scalar_one()
|
||||||
|
assert node.mac == "11:22:33:44:55:66"
|
||||||
|
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||||
|
assert mac_props == [
|
||||||
|
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||||
|
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||||
|
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 +924,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
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
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.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
from app.core.scheduler import (
|
||||||
|
_run_service_checks,
|
||||||
|
_run_status_checks,
|
||||||
|
set_service_checks_enabled,
|
||||||
|
start_scheduler,
|
||||||
|
stop_scheduler,
|
||||||
|
)
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
|
|
||||||
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
|
|||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
mock_settings.status_checker_interval = 45
|
mock_settings.status_checker_interval = 45
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
_, kwargs = mock_sched.add_job.call_args
|
_, kwargs = mock_sched.add_job.call_args
|
||||||
assert kwargs["seconds"] == 45
|
assert kwargs["seconds"] == 45
|
||||||
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
|
|||||||
mock_sched.add_job.assert_called_once()
|
mock_sched.add_job.assert_called_once()
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
mock_sched.shutdown.assert_called_once()
|
mock_sched.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
node = _make_node(
|
||||||
|
ip="10.0.0.5",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||||
|
)
|
||||||
|
session.add(node)
|
||||||
|
await session.commit()
|
||||||
|
node_id = node.id
|
||||||
|
|
||||||
|
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
||||||
|
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
|
||||||
|
mock_bcast.assert_awaited_once()
|
||||||
|
_, kwargs = mock_bcast.call_args
|
||||||
|
assert kwargs["node_id"] == node_id
|
||||||
|
assert kwargs["services"] == statuses
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||||
|
async with mem_db() as session:
|
||||||
|
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||||
|
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
await _run_service_checks()
|
||||||
|
mock_cs.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_service_checks_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
# Enable: no existing job -> add
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_service_checks_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
# Disable: existing job -> remove
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_service_checks_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("service_checks")
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_scheduler_adds_service_job_when_enabled():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
|
mock_settings.status_checker_interval = 60
|
||||||
|
mock_settings.service_check_enabled = True
|
||||||
|
mock_settings.service_check_interval = 300
|
||||||
|
start_scheduler()
|
||||||
|
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||||
|
assert "status_checks" in job_ids
|
||||||
|
assert "service_checks" in job_ids
|
||||||
|
|||||||
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
|||||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/settings", headers=headers)
|
||||||
|
data = res.json()
|
||||||
|
assert "service_check_enabled" in data
|
||||||
|
assert "service_check_interval" in data
|
||||||
|
assert isinstance(data["service_check_enabled"], bool)
|
||||||
|
assert isinstance(data["service_check_interval"], int)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
||||||
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||||
|
mock_settings.save_overrides = lambda: None
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={
|
||||||
|
"interval_seconds": 60,
|
||||||
|
"service_check_enabled": True,
|
||||||
|
"service_check_interval": 600,
|
||||||
|
},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert body["service_check_enabled"] is True
|
||||||
|
assert body["service_check_interval"] == 600
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/settings",
|
||||||
|
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 422
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -5,7 +5,13 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
from app.api.routes.status import (
|
||||||
|
_connections,
|
||||||
|
_drop,
|
||||||
|
broadcast_scan_update,
|
||||||
|
broadcast_service_status,
|
||||||
|
broadcast_status,
|
||||||
|
)
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
|
|||||||
assert len(_connections) == 0
|
assert len(_connections) == 0
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# broadcast_service_status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_service_status_payload():
|
||||||
|
received: list[str] = []
|
||||||
|
|
||||||
|
class FakeWS:
|
||||||
|
async def send_text(self, text: str) -> None:
|
||||||
|
received.append(text)
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
try:
|
||||||
|
await broadcast_service_status(
|
||||||
|
node_id="node-7",
|
||||||
|
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||||
|
checked_at="2024-01-01T00:00:00",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_drop(fake)
|
||||||
|
|
||||||
|
msg = json.loads(received[0])
|
||||||
|
assert msg["type"] == "service_status"
|
||||||
|
assert msg["node_id"] == "node-7"
|
||||||
|
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _drop — idempotent connection removal (regression for double-remove crash)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_drop_is_idempotent():
|
||||||
|
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
||||||
|
class FakeWS:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake = FakeWS()
|
||||||
|
_connections.append(fake)
|
||||||
|
_drop(fake)
|
||||||
|
_drop(fake) # second drop must be a no-op
|
||||||
|
assert fake not in _connections
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_broadcast_dead_connection_dropped_once_safely():
|
||||||
|
"""A send failure removes the dead socket without a double-remove crash."""
|
||||||
|
class DeadWS:
|
||||||
|
async def send_text(self, _: str) -> None:
|
||||||
|
raise RuntimeError("disconnected")
|
||||||
|
|
||||||
|
dead = DeadWS()
|
||||||
|
_connections.append(dead)
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
# A second broadcast must not raise even though dead is already gone.
|
||||||
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
|
assert dead not in _connections
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
from app.services.status_checker import (
|
||||||
|
_ping,
|
||||||
|
_tcp_connect,
|
||||||
|
check_node,
|
||||||
|
check_service,
|
||||||
|
check_services,
|
||||||
|
)
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -169,6 +175,36 @@ 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"]
|
||||||
|
# 2 probes so a single dropped packet doesn't flap the node offline
|
||||||
|
c_idx = captured["args"].index("-c")
|
||||||
|
assert captured["args"][c_idx + 1] == "2"
|
||||||
|
# Linux: -W is in seconds; 2s is the intended timeout
|
||||||
|
w_idx = captured["args"].index("-W")
|
||||||
|
assert captured["args"][w_idx + 1] == "2"
|
||||||
|
# IPv4 target → no -6 flag
|
||||||
|
assert "-6" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
@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] == "2000"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -191,6 +227,100 @@ async def test_ping_uses_windows_args_on_win32():
|
|||||||
assert "-c" not in captured["args"]
|
assert "-c" not in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- _ping IPv6 support ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_linux_uses_dash6():
|
||||||
|
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("fe80::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
assert captured["args"][-1] == "fe80::1"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_macos_uses_ping6():
|
||||||
|
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert captured["args"][0] == "ping6"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ping_ipv6_windows_uses_dash6():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_exec(*args, **kwargs):
|
||||||
|
captured["args"] = args
|
||||||
|
proc = MagicMock()
|
||||||
|
proc.returncode = 0
|
||||||
|
proc.wait = AsyncMock()
|
||||||
|
return proc
|
||||||
|
|
||||||
|
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||||
|
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||||
|
await _ping("2001:db8::1")
|
||||||
|
|
||||||
|
assert "-6" in captured["args"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ipv6_detection():
|
||||||
|
from app.services.status_checker import _is_ipv6
|
||||||
|
|
||||||
|
assert _is_ipv6("fe80::1") is True
|
||||||
|
assert _is_ipv6("2001:db8::1") is True
|
||||||
|
assert _is_ipv6("[2001:db8::1]") is True
|
||||||
|
assert _is_ipv6("192.168.1.1") is False
|
||||||
|
assert _is_ipv6("example.local") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_node target validation ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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
|
||||||
@@ -218,3 +348,117 @@ async def test_tcp_connect_os_error():
|
|||||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||||
result = await _tcp_connect("192.168.1.1", 9999)
|
result = await _tcp_connect("192.168.1.1", 9999)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- check_service ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_no_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_flag_host_is_unknown():
|
||||||
|
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_udp_is_unknown():
|
||||||
|
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_portless_non_web_is_unknown():
|
||||||
|
svc = {"protocol": "tcp", "service_name": "thing"}
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_uses_http_get():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "online"
|
||||||
|
assert captured["url"] == "http://10.0.0.1:8080"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_https_port_uses_https_scheme():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "10.0.0.1")
|
||||||
|
assert captured["url"].startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_web_offline_when_http_fails():
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_non_http_port_is_unknown():
|
||||||
|
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||||
|
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||||
|
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
mock_http.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ssh_port_22_is_unknown():
|
||||||
|
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||||
|
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||||
|
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||||
|
result = await check_service(svc, "10.0.0.1")
|
||||||
|
assert result == "unknown"
|
||||||
|
mock_tcp.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_ipv6_brackets_url_host():
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_http_get(url, verify=False):
|
||||||
|
captured["url"] = url
|
||||||
|
return True
|
||||||
|
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||||
|
await check_service(svc, "2001:db8::1")
|
||||||
|
assert captured["url"] == "http://[2001:db8::1]:80"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_returns_status_per_service():
|
||||||
|
services = [
|
||||||
|
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||||
|
]
|
||||||
|
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||||
|
results = await check_services("10.0.0.1", services)
|
||||||
|
assert results == [
|
||||||
|
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_services_empty_list():
|
||||||
|
assert await check_services("10.0.0.1", []) == []
|
||||||
|
|||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"""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_revives_orphaned_approved_device(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""Regression for #167: approve → delete node → re-import must re-list device.
|
||||||
|
|
||||||
|
When a device was approved (PendingDevice.status="approved") and its canvas
|
||||||
|
Node was later deleted, the orphaned "approved" row must be reset to
|
||||||
|
"pending" on re-import so it shows up in the Pending list again — instead of
|
||||||
|
being silently swallowed (re-import reports "found" but Pending stays empty).
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
# Simulate prior approve: a PendingDevice marked approved, but NO matching
|
||||||
|
# Node exists (the user deleted the canvas node afterwards).
|
||||||
|
orphan = PendingDevice(
|
||||||
|
ieee_address="0xR1",
|
||||||
|
friendly_name="router_1",
|
||||||
|
hostname="router_1",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
model="CC2530",
|
||||||
|
vendor="TI",
|
||||||
|
lqi=220,
|
||||||
|
status="approved",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(orphan)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
# No new row created for 0xR1 — the existing one was updated/revived.
|
||||||
|
revived = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert revived.status == "pending"
|
||||||
|
# End device 0xE1 is brand new → created as pending; router was updated.
|
||||||
|
assert result.pending_created == 1
|
||||||
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
# It is now visible to the Pending list (status filter == "pending").
|
||||||
|
listed = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
||||||
|
db_session,
|
||||||
|
) -> None:
|
||||||
|
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
|
hidden = PendingDevice(
|
||||||
|
ieee_address="0xR1",
|
||||||
|
friendly_name="router_1",
|
||||||
|
suggested_type="zigbee_router",
|
||||||
|
device_subtype="Router",
|
||||||
|
status="hidden",
|
||||||
|
discovery_source="zigbee",
|
||||||
|
)
|
||||||
|
db_session.add(hidden)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
|
still_hidden = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert still_hidden.status == "hidden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
||||||
|
"""If user has already made props visible, re-import must not flip them back."""
|
||||||
|
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
+452
-42
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "2.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "1.8.2",
|
"version": "2.5.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",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.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",
|
||||||
@@ -1645,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"
|
||||||
@@ -2011,6 +2012,415 @@
|
|||||||
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
"integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/primitive": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||||
|
"version": "1.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
|
||||||
|
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-escape-keydown": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-id": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-popper": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/react-dom": "^2.0.0",
|
||||||
|
"@radix-ui/react-arrow": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-rect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1",
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-portal": {
|
||||||
|
"version": "1.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz",
|
||||||
|
"integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-presence": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-popper": "1.2.8",
|
||||||
|
"@radix-ui/react-portal": "1.1.9",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-slot": "1.2.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-controllable-state": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-effect-event": "0.0.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-effect-event": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-escape-keydown": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-layout-effect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/rect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-size": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-visually-hidden": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-primitive": "2.1.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/rect": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -2827,9 +3237,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -3024,7 +3434,7 @@
|
|||||||
"version": "19.2.3",
|
"version": "19.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
@@ -3241,9 +3651,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -3776,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"
|
||||||
}
|
}
|
||||||
@@ -3840,9 +4250,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.13",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
|
||||||
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
|
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5192,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"
|
||||||
@@ -5283,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",
|
||||||
@@ -5447,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",
|
||||||
@@ -5786,9 +6196,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.11",
|
"version": "4.12.25",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.11.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz",
|
||||||
"integrity": "sha512-r4xbIa3mGGGoH9nN4A14DOg2wx7y2oQyJEb5O57C/xzETG/qx4c7CVDQ5WMeKHZ7ORk2W0hZ/sQKXTav3cmYBA==",
|
"integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -5944,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"
|
||||||
@@ -7468,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",
|
||||||
@@ -7630,9 +8040,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||||
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
|
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.10.2",
|
"version": "2.5.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,9 +19,10 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/inter": "^5.2.8",
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.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",
|
||||||
@@ -36,6 +37,9 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"hono": "^4.12.21"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
|||||||
@@ -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}`)
|
||||||
+389
-78
@@ -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,142 @@ 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 { SettingsModal } from '@/components/modals/SettingsModal'
|
||||||
|
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 { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
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, liveviewApi } 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, addToGroup, addToContainer } = 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 [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
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 [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||||
|
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = 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,37 +166,59 @@ 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)
|
||||||
const redoRef = useRef(redo)
|
const redoRef = useRef(redo)
|
||||||
const copyRef = useRef(copySelectedNodes)
|
|
||||||
const pasteRef = useRef(pasteNodes)
|
|
||||||
useEffect(() => { undoRef.current = undo }, [undo])
|
useEffect(() => { undoRef.current = undo }, [undo])
|
||||||
useEffect(() => { redoRef.current = redo }, [redo])
|
useEffect(() => { redoRef.current = redo }, [redo])
|
||||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
|
||||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
|
||||||
|
|
||||||
// Global keyboard shortcuts
|
// Global keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,8 +232,8 @@ export default function App() {
|
|||||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
// nodes under the cursor / viewport center.
|
||||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||||
}
|
}
|
||||||
window.addEventListener('keydown', handler)
|
window.addEventListener('keydown', handler)
|
||||||
@@ -227,6 +318,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()
|
||||||
@@ -307,12 +459,82 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||||
|
|
||||||
|
// Open the read-only live view of the currently active design in a new tab.
|
||||||
|
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||||
|
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||||
|
const handleViewOnly = useCallback(async () => {
|
||||||
|
if (STANDALONE) {
|
||||||
|
window.open('/view', '_blank', 'noopener,noreferrer')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await liveviewApi.getConfig()
|
||||||
|
if (!res.data.enabled || !res.data.key) {
|
||||||
|
toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({ key: res.data.key })
|
||||||
|
if (activeDesignId) params.set('design', activeDesignId)
|
||||||
|
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to open live view')
|
||||||
|
}
|
||||||
|
}, [activeDesignId])
|
||||||
|
|
||||||
const handleExport = useCallback(() => {
|
const handleExport = useCallback(() => {
|
||||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||||
if (!el) { toast.error('Canvas not ready'); return }
|
if (!el) { toast.error('Canvas not ready'); return }
|
||||||
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 +547,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 +565,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 +606,13 @@ 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}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
forceView={sidebarForceView}
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||||
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
|
||||||
@@ -399,6 +626,7 @@ export default function App() {
|
|||||||
onExportMd={handleExportMd}
|
onExportMd={handleExportMd}
|
||||||
onExportYaml={handleExportYaml}
|
onExportYaml={handleExportYaml}
|
||||||
onImportYaml={handleImportYaml}
|
onImportYaml={handleImportYaml}
|
||||||
|
onViewOnly={handleViewOnly}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-1 min-h-0">
|
<div className="flex flex-1 min-h-0">
|
||||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||||
@@ -407,14 +635,9 @@ export default function App() {
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={snapshotHistory}
|
onNodeDragStart={snapshotHistory}
|
||||||
onOpenPending={(deviceId) => {
|
onRequestAddToGroup={setPendingGroupAdd}
|
||||||
setHighlightPendingId(undefined)
|
onRequestAddToContainer={setPendingContainerAdd}
|
||||||
setSidebarForceView(undefined)
|
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||||
setTimeout(() => {
|
|
||||||
setHighlightPendingId(deviceId)
|
|
||||||
setSidebarForceView('pending')
|
|
||||||
}, 0)
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||||
@@ -428,9 +651,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, container_mode: n.data.container_mode }))}
|
||||||
.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 +662,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, container_mode: n.data.container_mode }))
|
||||||
|
})()}
|
||||||
|
currentNodeId={editNodeId ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -475,12 +712,28 @@ export default function App() {
|
|||||||
onClose={() => setScanConfigOpen(false)}
|
onClose={() => setScanConfigOpen(false)}
|
||||||
onScanNow={() => {
|
onScanNow={() => {
|
||||||
toast.success('Network scan started — check Scan History for results')
|
toast.success('Network scan started — check Scan History for results')
|
||||||
setSidebarForceView(undefined)
|
|
||||||
setTimeout(() => setSidebarForceView('history'), 0)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ZigbeeImportModal
|
||||||
|
open={zigbeeImportOpen}
|
||||||
|
onClose={() => setZigbeeImportOpen(false)}
|
||||||
|
onAddToCanvas={handleZigbeeAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
toast.success('Zigbee import started — check Scan History for results')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ScanHistoryModal
|
||||||
|
open={scanHistoryOpen}
|
||||||
|
onClose={() => setScanHistoryOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<GroupRectModal
|
<GroupRectModal
|
||||||
open={addGroupRectOpen}
|
open={addGroupRectOpen}
|
||||||
onClose={() => setAddGroupRectOpen(false)}
|
onClose={() => setAddGroupRectOpen(false)}
|
||||||
@@ -516,6 +769,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 +810,44 @@ 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)} />
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingGroupAdd}
|
||||||
|
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||||
|
setPendingGroupAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingGroupAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmAddToGroupModal
|
||||||
|
open={!!pendingContainerAdd}
|
||||||
|
variant="container"
|
||||||
|
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||||
|
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||||
|
setPendingContainerAdd(null)
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingContainerAdd(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PendingDevicesModal
|
||||||
|
open={pendingModalOpen}
|
||||||
|
onClose={() => setPendingModalOpen(false)}
|
||||||
|
highlightId={pendingHighlightId}
|
||||||
|
initialStatus={pendingModalStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
open={exportModalOpen}
|
open={exportModalOpen}
|
||||||
onClose={() => setExportModalOpen(false)}
|
onClose={() => setExportModalOpen(false)}
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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('liveviewApi.load forwards design as design_id when provided', () => {
|
||||||
|
mod.liveviewApi.load('k-1', 'design-9')
|
||||||
|
expect(publicApi.get).toHaveBeenCalledWith('/liveview', { params: { key: 'k-1', design_id: 'design-9' } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('liveviewApi.getConfig hits the authenticated config endpoint', () => {
|
||||||
|
mod.liveviewApi.getConfig()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/liveview/config')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('scanApi endpoints route correctly', () => {
|
||||||
|
mod.scanApi.trigger()
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/scan/trigger')
|
||||||
|
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, service_check_enabled: true, service_check_interval: 600 })
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/settings', { interval_seconds: 30, service_check_enabled: true, service_check_interval: 600 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zigbeeApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +53,9 @@ export const edgesApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const liveviewApi = {
|
export const liveviewApi = {
|
||||||
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
load: (key: string, design?: string) =>
|
||||||
|
publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }),
|
||||||
|
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
@@ -57,17 +64,95 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settingsApi = {
|
export interface AppSettings {
|
||||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
interval_seconds: number
|
||||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
service_check_enabled: boolean
|
||||||
|
service_check_interval: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsApi = {
|
||||||
|
get: () => api.get<AppSettings>('/settings'),
|
||||||
|
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const designsApi = {
|
||||||
|
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||||
|
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||||
|
api.post<import('@/types').Design>('/designs', data),
|
||||||
|
update: (id: string, data: { name?: string; icon?: string }) =>
|
||||||
|
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||||
|
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const zigbeeApi = {
|
||||||
|
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)
|
||||||
@@ -65,17 +68,24 @@ function LiveViewCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Already handled synchronously in useState initializer
|
// Already handled synchronously in useState initializer
|
||||||
const key = new URLSearchParams(window.location.search).get('key')
|
const search = new URLSearchParams(window.location.search)
|
||||||
|
const key = search.get('key')
|
||||||
if (!key) return
|
if (!key) return
|
||||||
|
// Optional ?design=<id> selects which canvas to render; backend falls back
|
||||||
|
// to the first design when omitted.
|
||||||
|
const design = search.get('design') ?? undefined
|
||||||
|
|
||||||
liveviewApi.load(key)
|
liveviewApi.load(key, design)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
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 +97,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 +113,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 +153,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}
|
||||||
@@ -140,6 +162,8 @@ function LiveViewCanvas() {
|
|||||||
elementsSelectable={false}
|
elementsSelectable={false}
|
||||||
panOnDrag
|
panOnDrag
|
||||||
zoomOnScroll
|
zoomOnScroll
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2.5}
|
||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, waitFor } from '@testing-library/react'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
|
|
||||||
|
// ── Capture the props ReactFlow is rendered with ──────────────────────────
|
||||||
|
const rfPropsSpy = vi.fn()
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
ReactFlow: (props: unknown) => {
|
||||||
|
rfPropsSpy(props)
|
||||||
|
return <div data-testid="react-flow" />
|
||||||
|
},
|
||||||
|
Background: () => null,
|
||||||
|
Controls: () => null,
|
||||||
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
|
ConnectionMode: { Loose: 'loose' },
|
||||||
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
|
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||||
|
}))
|
||||||
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
|
vi.mock('@/api/client', () => ({ liveviewApi: { load: vi.fn() } }))
|
||||||
|
|
||||||
|
import { liveviewApi } from '@/api/client'
|
||||||
|
import LiveView from '../LiveView'
|
||||||
|
|
||||||
|
function setSearch(params: string) {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, search: params, pathname: '/view' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a /liveview API response with the given nodes/edges. */
|
||||||
|
const apiResponse = (nodes: unknown[], edges: unknown[] = []) => ({
|
||||||
|
data: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiNode = (
|
||||||
|
id: string,
|
||||||
|
parent_id?: string,
|
||||||
|
collapsed?: boolean,
|
||||||
|
type = 'server',
|
||||||
|
) => ({
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label: id,
|
||||||
|
status: 'online',
|
||||||
|
services: [],
|
||||||
|
pos_x: 0,
|
||||||
|
pos_y: 0,
|
||||||
|
parent_id: parent_id ?? null,
|
||||||
|
container_mode: type === 'group',
|
||||||
|
custom_colors: collapsed !== undefined ? { collapsed } : null,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('LiveView — applies collapse filter to the rendered canvas', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
rfPropsSpy.mockClear()
|
||||||
|
setSearch('?key=valid')
|
||||||
|
vi.mocked(liveviewApi.load).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides children of a collapsed group container in view-only mode', async () => {
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||||
|
apiResponse([apiNode('g1', undefined, true, 'group'), apiNode('c1', 'g1')]),
|
||||||
|
)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||||
|
| { nodes: Node<NodeData>[] }
|
||||||
|
| undefined
|
||||||
|
expect(last?.nodes.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||||
|
nodes: Node<NodeData>[]
|
||||||
|
edges: Edge<EdgeData>[]
|
||||||
|
}
|
||||||
|
const ids = last.nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain('g1')
|
||||||
|
expect(ids).not.toContain('c1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows children when the group is expanded', async () => {
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||||
|
apiResponse([apiNode('g1', undefined, false, 'group'), apiNode('c1', 'g1')]),
|
||||||
|
)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||||
|
| { nodes: Node<NodeData>[] }
|
||||||
|
| undefined
|
||||||
|
expect(last?.nodes.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||||
|
nodes: Node<NodeData>[]
|
||||||
|
}
|
||||||
|
const ids = last.nodes.map((n) => n.id)
|
||||||
|
expect(ids).toContain('g1')
|
||||||
|
expect(ids).toContain('c1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { 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 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
|
||||||
|
let rfProps: Record<string, unknown> = {}
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
ReactFlow: () => <div data-testid="react-flow" />,
|
ReactFlow: (props: Record<string, unknown>) => {
|
||||||
|
rfProps = props
|
||||||
|
return <div data-testid="react-flow" />
|
||||||
|
},
|
||||||
Background: () => null,
|
Background: () => null,
|
||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
@@ -48,6 +55,7 @@ const canvasPayload = {
|
|||||||
|
|
||||||
describe('LiveView (non-standalone)', () => {
|
describe('LiveView (non-standalone)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
rfProps = {}
|
||||||
vi.mocked(liveviewApi.load).mockReset()
|
vi.mocked(liveviewApi.load).mockReset()
|
||||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||||
})
|
})
|
||||||
@@ -110,7 +118,26 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('forwards ?design=<id> to the API so a specific canvas is loaded', async () => {
|
||||||
|
setSearch('?key=correct-key&design=elec-123')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
|
||||||
|
setSearch('?key=correct-key')
|
||||||
|
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||||
|
render(<LiveView />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||||
|
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
|
||||||
|
// can't zoom out far enough to fit.
|
||||||
|
expect(rfProps.minZoom).toBe(0.25)
|
||||||
|
expect(rfProps.maxZoom).toBe(2.5)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads nodes into the canvas store on success', async () => {
|
it('loads nodes into the canvas store on success', async () => {
|
||||||
@@ -124,6 +151,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
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, useRef, 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 {
|
||||||
@@ -27,18 +30,46 @@ interface CanvasContainerProps {
|
|||||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||||
onNodeDragStart?: () => void
|
onNodeDragStart?: () => void
|
||||||
|
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||||
|
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||||
onOpenPending?: (deviceId: string) => void
|
onOpenPending?: (deviceId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||||
const [lassoMode, setLassoMode] = useState(true)
|
const [lassoMode, setLassoMode] = useState(true)
|
||||||
const {
|
const {
|
||||||
nodes, edges,
|
nodes, edges,
|
||||||
onNodesChange, onEdgesChange,
|
onNodesChange, onEdgesChange,
|
||||||
setSelectedNode, snapshotHistory,
|
setSelectedNode, snapshotHistory,
|
||||||
fitViewPending, clearFitViewPending,
|
fitViewPending, clearFitViewPending,
|
||||||
|
copySelectedNodes, pasteNodes,
|
||||||
} = useCanvasStore()
|
} = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||||
|
|
||||||
|
// Track the last cursor position over the canvas so paste lands under it.
|
||||||
|
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||||
|
// can project the cursor / viewport center into flow coordinates.
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (!(e.ctrlKey || e.metaKey)) return
|
||||||
|
const el = e.target as HTMLElement
|
||||||
|
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
|
||||||
|
if (isInput) return
|
||||||
|
if (e.key === 'c') {
|
||||||
|
copySelectedNodes()
|
||||||
|
} else if (e.key === 'v') {
|
||||||
|
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||||
|
pasteNodes(screenToFlowPosition(screen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
|
||||||
|
|
||||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -53,6 +84,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 +125,31 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||||
|
|
||||||
|
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||||
|
// before the alignment snap so detection uses the dropped position.
|
||||||
|
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||||
|
if (dragNode && !dragNode.parentId &&
|
||||||
|
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||||
|
const intersecting = getIntersectingNodes(dragNode)
|
||||||
|
const group = intersecting.find((n) => n.data.type === 'group')
|
||||||
|
if (group) {
|
||||||
|
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||||
|
} else {
|
||||||
|
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||||
|
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||||
|
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onNodeDragStop(event, dragNode, dragNodes)
|
||||||
|
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={visibleNodes}
|
||||||
edges={edges}
|
edges={visibleEdges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnectProp}
|
onConnect={onConnectProp}
|
||||||
@@ -96,6 +158,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||||
onNodeDoubleClick={handleNodeDoubleClick}
|
onNodeDoubleClick={handleNodeDoubleClick}
|
||||||
onNodeDragStart={onNodeDragStart}
|
onNodeDragStart={onNodeDragStart}
|
||||||
|
onNodeDrag={onNodeDrag}
|
||||||
|
onNodeDragStop={handleNodeDragStop}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
edgeTypes={edgeTypes}
|
edgeTypes={edgeTypes}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
@@ -121,6 +185,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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -20,7 +20,9 @@ vi.mock('@/stores/themeStore', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/stores/canvasStore', () => ({
|
vi.mock('@/stores/canvasStore', () => ({
|
||||||
useCanvasStore: (sel: (s: { hideIp: boolean }) => unknown) => sel({ hideIp: false }),
|
useCanvasStore: (sel: (s: { hideIp: boolean; serviceStatuses: Record<string, string> }) => unknown) =>
|
||||||
|
sel({ hideIp: false, serviceStatuses: {} }),
|
||||||
|
serviceStatusKey: (nodeId: string, port?: number, protocol?: string) => `${nodeId}:${port ?? ''}/${protocol ?? ''}`,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/themes', () => ({
|
vi.mock('@/utils/themes', () => ({
|
||||||
@@ -44,11 +46,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 +60,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 +176,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({
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type { NodeData, EdgeData } from '@/types'
|
|||||||
// Capture props passed to ReactFlow so we can test the callbacks
|
// Capture props passed to ReactFlow so we can test the callbacks
|
||||||
let rfProps: Record<string, unknown> = {}
|
let rfProps: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
// Hoisted holder so the mock factory can read the configurable intersection set.
|
||||||
|
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
|
||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
ReactFlow: (props: Record<string, unknown>) => {
|
ReactFlow: (props: Record<string, unknown>) => {
|
||||||
rfProps = props
|
rfProps = props
|
||||||
@@ -21,7 +24,13 @@ vi.mock('@xyflow/react', () => ({
|
|||||||
ConnectionMode: { Loose: 'loose' },
|
ConnectionMode: { Loose: 'loose' },
|
||||||
SelectionMode: { Partial: 'partial' },
|
SelectionMode: { Partial: 'partial' },
|
||||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
useReactFlow: () => ({
|
||||||
|
fitView: vi.fn(),
|
||||||
|
screenToFlowPosition: vi.fn(),
|
||||||
|
getIntersectingNodes: () => rf.intersecting,
|
||||||
|
setNodes: vi.fn(),
|
||||||
|
getNodes: () => [],
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||||
@@ -42,6 +51,7 @@ function makeEdge(id: string): Edge<EdgeData> {
|
|||||||
describe('CanvasContainer', () => {
|
describe('CanvasContainer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rfProps = {}
|
rfProps = {}
|
||||||
|
rf.intersecting = []
|
||||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||||
useThemeStore.setState({ activeTheme: 'default' })
|
useThemeStore.setState({ activeTheme: 'default' })
|
||||||
})
|
})
|
||||||
@@ -154,6 +164,93 @@ describe('CanvasContainer', () => {
|
|||||||
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
expect(rfProps.onNodeDragStart).toBe(onNodeDragStart)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Drag onto group → onRequestAddToGroup ─────────────────────────────────
|
||||||
|
|
||||||
|
function groupNode(id: string): Node<NodeData> {
|
||||||
|
return { id, type: 'group', position: { x: 0, y: 0 }, data: { label: id, type: 'group', status: 'unknown', services: [] } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToGroup when a node is dropped over a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
const group = groupNode('g1')
|
||||||
|
rf.intersecting = [group]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when no group is under the node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup for an already-parented node', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'gOther' }
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToGroup when the dragged node is itself a group', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const node = groupNode('g2')
|
||||||
|
rf.intersecting = [groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Drag onto container node → onRequestAddToContainer ────────────────────
|
||||||
|
|
||||||
|
function containerNode(id: string, type: NodeData['type'] = 'proxmox'): Node<NodeData> {
|
||||||
|
return { id, type, position: { x: 0, y: 0 }, data: { label: id, type, status: 'unknown', services: [], container_mode: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('fires onRequestAddToContainer when a node is dropped over a container_mode node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers a group over a container when both intersect', () => {
|
||||||
|
const onRequestAddToGroup = vi.fn()
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||||
|
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer for an already-parented node', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = { ...makeNode('n1'), parentId: 'pxOther' }
|
||||||
|
rf.intersecting = [containerNode('px1')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not fire onRequestAddToContainer when the target node is not in container_mode', () => {
|
||||||
|
const onRequestAddToContainer = vi.fn()
|
||||||
|
const node = makeNode('n1')
|
||||||
|
rf.intersecting = [makeNode('n2')]
|
||||||
|
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||||
|
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||||
|
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('enables snapToGrid', () => {
|
it('enables snapToGrid', () => {
|
||||||
|
|||||||
@@ -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,77 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import { HomelableEdge } from '../index'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: edge flow animations must use CSS, never SVG SMIL <animate>.
|
||||||
|
*
|
||||||
|
* SMIL <animate> keeps running while the tab is hidden and leaks memory in
|
||||||
|
* Chrome over time (RAM climbed only when the canvas tab was backgrounded).
|
||||||
|
* CSS animations pause when the tab is hidden and don't leak — so the rendered
|
||||||
|
* output must contain a CSS `animation` on the path and zero <animate> nodes.
|
||||||
|
*/
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge animation', () => {
|
||||||
|
it('renders snake animation as CSS, not SMIL <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: 'snake' })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders flow animation as CSS, not SMIL <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: 'flow' })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-flow'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('legacy animated:true maps to snake CSS animation', () => {
|
||||||
|
const { container } = renderEdge({ animated: true })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||||
|
(p.getAttribute('style') ?? '').includes('homelable-snake'),
|
||||||
|
)
|
||||||
|
expect(animated).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('non-animated edge has no flow animation and no <animate>', () => {
|
||||||
|
const { container } = renderEdge({ animated: false })
|
||||||
|
expect(container.querySelector('animate')).toBeNull()
|
||||||
|
const animated = Array.from(container.querySelectorAll('path')).find((p) => {
|
||||||
|
const s = p.getAttribute('style') ?? ''
|
||||||
|
return s.includes('homelable-snake') || s.includes('homelable-flow')
|
||||||
|
})
|
||||||
|
expect(animated).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #183 — connection labels must support multiple lines.
|
||||||
|
*
|
||||||
|
* The label is a free-text string; newlines entered in the EdgeModal textarea
|
||||||
|
* are stored verbatim. The rendered label div must preserve those newlines
|
||||||
|
* (`whitespace-pre-line`) instead of collapsing them into a single line.
|
||||||
|
*
|
||||||
|
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
|
||||||
|
* full <ReactFlow> host, so we stub it to a passthrough to render the label
|
||||||
|
* markup directly.
|
||||||
|
*/
|
||||||
|
vi.mock('@xyflow/react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@xyflow/react')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { HomelableEdge } = await import('../index')
|
||||||
|
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge label', () => {
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const { getByText } = renderEdge({ label: 'uplink' })
|
||||||
|
expect(getByText('uplink')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in the rendered label (issue #183)', () => {
|
||||||
|
const { container } = renderEdge({ label: 'line one\nline two' })
|
||||||
|
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
|
||||||
|
d.textContent === 'line one\nline two',
|
||||||
|
)
|
||||||
|
expect(label).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label div when label is empty', () => {
|
||||||
|
const { container } = renderEdge({ label: undefined })
|
||||||
|
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
@@ -271,14 +378,14 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
strokeWidth={((style.strokeWidth as number ?? 2) + 1.5) * 2}
|
||||||
strokeDasharray="20 10000"
|
strokeDasharray="20 10000"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
{isBidirectional ? (
|
// CSS (not SMIL) so it pauses when the tab is hidden — see index.css.
|
||||||
<animate attributeName="stroke-dashoffset" values="-10000;0;-10000" keyTimes="0;0.5;1" dur="20s" repeatCount="indefinite" />
|
// Bidirectional yo-yos via `alternate` (10s each way = 20s round trip,
|
||||||
) : (
|
// matching the old SMIL keyTimes); unidirectional loops in one direction.
|
||||||
<animate attributeName="stroke-dashoffset" from="-10000" to="0" dur="10s" repeatCount="indefinite" />
|
animation: `homelable-snake 10s linear infinite${isBidirectional ? ' alternate' : ''}`,
|
||||||
)}
|
}}
|
||||||
</path>
|
/>
|
||||||
)}
|
)}
|
||||||
{animMode === 'flow' && (
|
{animMode === 'flow' && (
|
||||||
<path
|
<path
|
||||||
@@ -289,18 +396,19 @@ export function HomelableEdge({ id, source, target, sourceX, sourceY, targetX, t
|
|||||||
strokeDasharray="6 12"
|
strokeDasharray="6 12"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeOpacity={0.85}
|
strokeOpacity={0.85}
|
||||||
style={{ pointerEvents: 'none' }}
|
style={{
|
||||||
>
|
pointerEvents: 'none',
|
||||||
<animate attributeName="stroke-dashoffset" from="0" to="18" dur="1.2s" repeatCount="indefinite" />
|
animation: 'homelable-flow 1.2s linear infinite',
|
||||||
</path>
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<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 whitespace-pre-line text-center"
|
||||||
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 +418,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, serviceStatusKey } 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
|
||||||
@@ -29,12 +31,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
|
|
||||||
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(typeIcon, data.custom_icon)
|
||||||
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 +62,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 +73,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 +83,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 +100,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 +137,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 +146,75 @@ 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 svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
|
||||||
|
const row = (
|
||||||
|
<div
|
||||||
|
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
|
||||||
|
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, color: svcOffline ? '#f85149' : undefined }}
|
||||||
|
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 +224,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 +251,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,59 @@
|
|||||||
|
import { Layers } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
interface ConfirmAddToGroupModalProps {
|
||||||
|
open: boolean
|
||||||
|
nodeLabel: string
|
||||||
|
/** Label of the destination group/container. */
|
||||||
|
targetLabel: string
|
||||||
|
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||||
|
variant?: 'group' | 'container'
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmAddToGroupModal({
|
||||||
|
open,
|
||||||
|
nodeLabel,
|
||||||
|
targetLabel,
|
||||||
|
variant = 'group',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmAddToGroupModalProps) {
|
||||||
|
const action = variant === 'container' ? 'Add to container' : 'Add to group'
|
||||||
|
const noun = variant === 'container' ? 'container' : 'group'
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Layers size={16} className="text-[#00d4ff]" />
|
||||||
|
{action}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||||
|
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onCancel}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{action}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,8 +1,10 @@
|
|||||||
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'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
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 { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
||||||
@@ -43,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
type,
|
||||||
label: label || undefined,
|
label: label.trim() || undefined,
|
||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
@@ -68,8 +70,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,30 +91,33 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
|
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
|
||||||
<Input
|
<Textarea
|
||||||
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...\nsecond line'}
|
||||||
className="bg-[#21262d] border-[#30363d] text-sm h-8"
|
rows={2}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${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 +132,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 +167,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 +198,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,41 @@ const DEFAULT_DATA: Partial<NodeData> = {
|
|||||||
custom_icon: undefined,
|
custom_icon: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ParentCandidate {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
type: NodeType
|
||||||
|
/** True when the node has container mode on, so any node can nest inside it. */
|
||||||
|
container_mode?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeModalProps {
|
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 +99,27 @@ 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)
|
||||||
|
// A parent is valid either by the type rules (lxc/vm/docker_container) or
|
||||||
|
// because the candidate is a container-mode node (any child can nest in it).
|
||||||
|
const isValidParent = (p: ParentCandidate) =>
|
||||||
|
validParentTypes.includes(p.type) || p.container_mode === true
|
||||||
|
let safeParentId = form.parent_id
|
||||||
|
if (safeParentId) {
|
||||||
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
|
if (!parent || !isValidParent(parent)) 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 +129,21 @@ 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
|
||||||
|
// Drop the parent only if it's no longer a valid target for the
|
||||||
|
// new type — keep container-mode parents (any node can nest).
|
||||||
|
const parent = parentCandidates.find((n) => n.id === f.parent_id)
|
||||||
|
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
|
||||||
|
next.parent_id = undefined
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}}>
|
||||||
|
<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 +184,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 +208,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 +265,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 +284,8 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -210,7 +296,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 +308,140 @@ 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)
|
||||||
|
// Candidates: type-based parents (lxc/vm/docker_container) plus any
|
||||||
|
// container-mode node. The current parent is always kept so an
|
||||||
|
// already-nested node can be re-targeted or detached here.
|
||||||
|
const validParents = parentCandidates.filter(
|
||||||
|
(n) => n.id !== currentNodeId &&
|
||||||
|
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
|
||||||
|
)
|
||||||
|
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 +451,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 +479,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 +498,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 +557,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,706 @@
|
|||||||
|
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, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
|
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||||
|
import { buildMacProperty } from '@/utils/macProperty'
|
||||||
|
|
||||||
|
interface PendingDevicesModalProps {
|
||||||
|
open: boolean
|
||||||
|
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) : buildMacProperty(device.mac)
|
||||||
|
const nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
mac: device.mac ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: zigbee ? 'online' : 'unknown',
|
||||||
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
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,
|
||||||
|
mac: d.mac ?? undefined,
|
||||||
|
hostname: d.hostname ?? undefined,
|
||||||
|
status: zigbee ? ('online' as const) : ('unknown' as const),
|
||||||
|
services: (d.services ?? []) as ServiceInfo[],
|
||||||
|
properties: zigbee ? buildZigbeeProperties(d) : buildMacProperty(d.mac),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
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) {
|
||||||
|
// Enter confirms the bulk action for the current view: approving
|
||||||
|
// hidden devices would be wrong — they restore.
|
||||||
|
e.preventDefault()
|
||||||
|
if (statusFilter === 'hidden') handleBulkRestore()
|
||||||
|
else handleBulkApprove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handler)
|
||||||
|
return () => window.removeEventListener('keydown', handler)
|
||||||
|
// statusFilter is included so Enter dispatches the correct bulk action
|
||||||
|
// (approve vs restore) even if the device list doesn't change on switch.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, selectMode, selectedIds, filtered, statusFilter])
|
||||||
|
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{/* Route the close X through Base UI's DialogClose (same path as
|
||||||
|
outside-click) instead of a raw onClick — the latter's synthetic
|
||||||
|
click was being dropped on Firefox/Windows. */}
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</DialogClose>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
|
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, Inbox } from 'lucide-react'
|
||||||
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { scanApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
export interface ScanRun {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind?: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScanHistoryModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type KindFilter = 'all' | 'ip' | 'zigbee'
|
||||||
|
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
|
||||||
|
const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'running', label: 'Running' },
|
||||||
|
{ key: 'done', label: 'Done' },
|
||||||
|
{ key: 'error', label: 'Error' },
|
||||||
|
{ key: 'cancelled', label: 'Cancelled' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
||||||
|
{ key: 'all', label: 'All' },
|
||||||
|
{ key: 'ip', label: 'IP' },
|
||||||
|
{ key: 'zigbee', label: 'Zigbee' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function statusColor(s: string): string {
|
||||||
|
return s === 'done' ? '#39d353'
|
||||||
|
: s === 'running' ? '#e3b341'
|
||||||
|
: s === 'error' ? '#f85149'
|
||||||
|
: s === 'cancelled' ? '#8b949e'
|
||||||
|
: '#8b949e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUtc(ts: string): number {
|
||||||
|
return new Date(ts.endsWith('Z') ? ts : ts + 'Z').getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 0) ms = 0
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
if (s < 60) return `${s}s`
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const rem = s % 60
|
||||||
|
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDuration(r: ScanRun, now: number): string {
|
||||||
|
const start = parseUtc(r.started_at)
|
||||||
|
const end = r.finished_at ? parseUtc(r.finished_at) : now
|
||||||
|
return formatDuration(end - start)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
||||||
|
const [runs, setRuns] = useState<ScanRun[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [stopping, setStopping] = useState<string | null>(null)
|
||||||
|
const [kindFilter, setKindFilter] = useState<KindFilter>('all')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [now, setNow] = useState(() => Date.now())
|
||||||
|
const prevRunsRef = useRef<ScanRun[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await scanApi.runs()
|
||||||
|
const next: ScanRun[] = res.data
|
||||||
|
|
||||||
|
// Surface transitions and refresh dependent UI
|
||||||
|
for (const run of next) {
|
||||||
|
const prev = prevRunsRef.current.find((r) => r.id === run.id)
|
||||||
|
if (prev?.status === 'running' && run.status === 'error') {
|
||||||
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
|
}
|
||||||
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
|
if (run.kind === 'zigbee') {
|
||||||
|
toast.success(`Zigbee import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevRunsRef.current = next
|
||||||
|
setRuns(next)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load scan history')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Load when opened; reset prior-state tracker so we don't replay old transitions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
prevRunsRef.current = []
|
||||||
|
load()
|
||||||
|
}, [open, load])
|
||||||
|
|
||||||
|
// Auto-refresh every 3s while any run is still running (only when open)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(load, 3000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs, load])
|
||||||
|
|
||||||
|
// Tick the clock every second while a scan is running (for live elapsed duration)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const hasRunning = runs.some((r) => r.status === 'running')
|
||||||
|
if (!hasRunning) return
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [open, runs])
|
||||||
|
|
||||||
|
const handleStop = async (runId: string) => {
|
||||||
|
setStopping(runId)
|
||||||
|
try {
|
||||||
|
await scanApi.stop(runId)
|
||||||
|
toast.success('Scan stop requested')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to stop scan')
|
||||||
|
} finally {
|
||||||
|
setStopping(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = runs.filter((r) => {
|
||||||
|
const k = r.kind === 'zigbee' ? 'zigbee' : 'ip'
|
||||||
|
if (kindFilter !== 'all' && k !== kindFilter) return false
|
||||||
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||||
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="!max-w-none w-[90vw] max-w-2xl h-[80vh] p-0 flex flex-col gap-0 bg-[#0d1117] border-border"
|
||||||
|
>
|
||||||
|
<DialogHeader className="px-4 py-3 border-b border-border shrink-0">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<DialogTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
|
<Clock size={16} className="text-[#00d4ff]" />
|
||||||
|
Scan History
|
||||||
|
<span className="text-muted-foreground font-normal text-xs">
|
||||||
|
({filtered.length}{filtered.length !== runs.length && ` of ${runs.length}`})
|
||||||
|
</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={load} className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors" title="Refresh">
|
||||||
|
<RefreshCw size={14} className={loading ? 'animate-spin' : undefined} />
|
||||||
|
</button>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
className="text-muted-foreground hover:text-foreground p-1.5 rounded transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="px-4 py-2 border-b border-border bg-[#161b22] shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Type</span>
|
||||||
|
{KIND_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={kindFilter === f.key} onClick={() => setKindFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Status</span>
|
||||||
|
{STATUS_FILTERS.map((f) => (
|
||||||
|
<FilterChip key={f.key} active={statusFilter === f.key} onClick={() => setStatusFilter(f.key)}>
|
||||||
|
{f.label}
|
||||||
|
</FilterChip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-2">
|
||||||
|
{loading && runs.length === 0 && (
|
||||||
|
<div className="flex justify-center py-10">
|
||||||
|
<Loader2 size={18} className="animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && filtered.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-12 text-muted-foreground">
|
||||||
|
<Inbox size={28} className="opacity-50" />
|
||||||
|
<p className="text-sm">{runs.length === 0 ? 'No scans yet' : 'No scans match the filters'}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const isZigbee = r.kind === 'zigbee'
|
||||||
|
return (
|
||||||
|
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: statusColor(r.status) }} />
|
||||||
|
<span className="font-mono text-sm text-foreground capitalize">{r.status}</span>
|
||||||
|
{r.status === 'running' && <Loader2 size={12} className="animate-spin text-[#e3b341]" />}
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={isZigbee
|
||||||
|
? { background: '#00d4ff22', color: '#00d4ff' }
|
||||||
|
: { background: '#a855f722', color: '#a855f7' }}
|
||||||
|
>
|
||||||
|
{isZigbee ? <Network size={10} /> : <ScanLine size={10} />}
|
||||||
|
{isZigbee ? 'Zigbee' : 'IP'}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground font-mono">
|
||||||
|
{r.devices_found} found
|
||||||
|
</span>
|
||||||
|
{r.status === 'running' && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<button
|
||||||
|
aria-label="Stop scan"
|
||||||
|
onClick={() => handleStop(r.id)}
|
||||||
|
disabled={stopping === r.id}
|
||||||
|
className="p-1 text-[#f85149] hover:bg-[#f85149]/10 rounded transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{stopping === r.id
|
||||||
|
? <Loader2 size={13} className="animate-spin" />
|
||||||
|
: <StopCircle size={13} />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="left">Stop scan</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta grid */}
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px]">
|
||||||
|
<Meta label="Started" value={new Date(parseUtc(r.started_at)).toLocaleString()} />
|
||||||
|
<Meta
|
||||||
|
label="Finished"
|
||||||
|
value={r.finished_at ? new Date(parseUtc(r.finished_at)).toLocaleString() : '—'}
|
||||||
|
/>
|
||||||
|
<Meta label="Duration" value={runDuration(r, now)} mono />
|
||||||
|
<Meta label="Devices" value={`${r.devices_found}`} mono />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{r.ranges.length > 0 && (
|
||||||
|
<div className="mt-2 text-[11px]">
|
||||||
|
<span className="text-muted-foreground">Ranges: </span>
|
||||||
|
<span className="text-[#8b949e] font-mono break-all">{r.ranges.join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{r.error && (
|
||||||
|
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
||||||
|
{r.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors cursor-pointer ${
|
||||||
|
active
|
||||||
|
? 'bg-[#00d4ff]/10 border-[#00d4ff]/40 text-[#00d4ff]'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-muted-foreground text-[10px] uppercase tracking-wider">{label}</span>
|
||||||
|
<span className={`text-foreground ${mono ? 'font-mono' : ''}`}>{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,192 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { settingsApi } from '@/api/client'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import {
|
||||||
|
type AlignmentSettings,
|
||||||
|
readAlignmentSettings,
|
||||||
|
writeAlignmentSettings,
|
||||||
|
subscribeAlignmentSettings,
|
||||||
|
} from '@/utils/alignmentSettings'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||||
|
const [interval, setIntervalValue] = useState(60)
|
||||||
|
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||||
|
const [serviceInterval, setServiceInterval] = useState(300)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || STANDALONE) return
|
||||||
|
settingsApi.get()
|
||||||
|
.then((res) => {
|
||||||
|
setIntervalValue(res.data.interval_seconds)
|
||||||
|
setServiceCheckEnabled(res.data.service_check_enabled)
|
||||||
|
setServiceInterval(res.data.service_check_interval)
|
||||||
|
})
|
||||||
|
.catch(() => {/* use default */})
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
|
||||||
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
|
const next = { ...alignment, ...patch }
|
||||||
|
setAlignment(next)
|
||||||
|
writeAlignmentSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
||||||
|
// status-check interval needs an API round-trip.
|
||||||
|
if (STANDALONE) {
|
||||||
|
onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await settingsApi.save({
|
||||||
|
interval_seconds: interval,
|
||||||
|
service_check_enabled: serviceCheckEnabled,
|
||||||
|
service_check_interval: serviceInterval,
|
||||||
|
})
|
||||||
|
toast.success('Settings saved')
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save settings')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-5 py-2">
|
||||||
|
{/* Status checker */}
|
||||||
|
{!STANDALONE && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Status check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={10}
|
||||||
|
max={3600}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
How often node health is polled (ping, HTTP, SSH…)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer pt-2">
|
||||||
|
<span className="text-xs text-foreground">Check services individually</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={serviceCheckEnabled}
|
||||||
|
onChange={(e) => setServiceCheckEnabled(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle per-service status checks"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={serviceCheckEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Service check interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
max={3600}
|
||||||
|
value={serviceInterval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setServiceInterval(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Service check interval"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Probes each service port. Offline services turn red. Default 300s (5 min).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Canvas */}
|
||||||
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Canvas</span>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Snap to nodes</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={alignment.enabled}
|
||||||
|
onChange={(e) => updateAlignment({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle alignment guides"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Hide IP addresses</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideIp}
|
||||||
|
onChange={(e) => setHideIp(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle IP address masking"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={alignment.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Snap distance</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={2}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
value={alignment.threshold}
|
||||||
|
onChange={(e) => updateAlignment({ threshold: Number(e.target.value) })}
|
||||||
|
className="flex-1 cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Alignment snap threshold"
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-[11px] text-foreground w-8 text-right">{alignment.threshold}px</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{ background: '#00d4ff', color: '#0d1117' }}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,46 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { ConfirmAddToGroupModal } from '../ConfirmAddToGroupModal'
|
||||||
|
|
||||||
|
describe('ConfirmAddToGroupModal', () => {
|
||||||
|
it('renders nothing when closed', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open={false} nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows node and group labels when open', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByText('Router')).toBeDefined()
|
||||||
|
expect(screen.getByText('DMZ')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when the confirm button is clicked', () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={onConfirm} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /add to group/i }))
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when the cancel button is clicked', () => {
|
||||||
|
const onCancel = vi.fn()
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open nodeLabel="Router" targetLabel="DMZ" onConfirm={vi.fn()} onCancel={onCancel} />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||||
|
expect(onCancel).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses container wording when variant is container', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmAddToGroupModal open variant="container" nodeLabel="VM" targetLabel="Proxmox" onConfirm={vi.fn()} onCancel={vi.fn()} />,
|
||||||
|
)
|
||||||
|
expect(screen.getByRole('button', { name: /add to container/i })).toBeDefined()
|
||||||
|
expect(screen.queryByText('Add to group')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,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')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user