Compare commits
393 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7b4db206b | |||
| a05c82b4f6 | |||
| 9a066a3d6e | |||
| f0af367c34 | |||
| 46185d187f | |||
| fd2c50c1aa | |||
| 8b9972ca78 | |||
| 313feb8ceb | |||
| 23673eb0ca | |||
| cbc2bc03c2 | |||
| 7e99d77edc | |||
| da2c1c356a | |||
| 3cedb40d17 | |||
| 7fdce6af37 | |||
| c1b0c42f0c | |||
| 2a8c9d618b | |||
| e811d83ceb | |||
| c8d25c2383 | |||
| 3020b400ed | |||
| 8f8d9fe209 | |||
| 612280e924 | |||
| 19b7d38ec0 | |||
| b3f7c38c67 | |||
| 5b20d4fd75 | |||
| 16e2dedab0 | |||
| c8ed63712d | |||
| 10fdab52af | |||
| eddf752b50 | |||
| 13420bead8 | |||
| d41896fadf | |||
| f749b38edc | |||
| ecf3cbdfe4 | |||
| 5b08d57124 | |||
| 9b8f15bec3 | |||
| 8faf5c1c79 | |||
| c6076d133a | |||
| 662a240b05 | |||
| dfeb981e40 | |||
| 430c14ed5e | |||
| 234019c7b7 | |||
| d7ab4ba49a | |||
| 96107cc657 | |||
| c68084b751 | |||
| d01630bf37 | |||
| b6423c0115 | |||
| 9d1ebd6c6e | |||
| 44e0029f2b | |||
| b20c47a0a5 | |||
| be5771f7d0 | |||
| 5d62df45f7 | |||
| 83b296747c | |||
| 77718a5239 | |||
| 40f3edce68 | |||
| 1efc1c95ca | |||
| b70ee78d25 | |||
| 9efaaccb51 | |||
| 6e5bda5860 | |||
| 0796c96fc1 | |||
| 6fba0cdec4 | |||
| 2a4d109ee6 | |||
| 96bb048228 | |||
| 88c006764a | |||
| 9c3dace12f | |||
| ec53363fdb | |||
| 0e260ede73 | |||
| 60902167c0 | |||
| 592e7865c5 | |||
| f082c295fd | |||
| 10b981ad1d | |||
| 2058e453ff | |||
| b776b6b8f1 | |||
| fe3787f0cd | |||
| 7ea1fc2d69 | |||
| 71cc183efb | |||
| 7873f8b94f | |||
| b34b40794d | |||
| ed310f0d8d | |||
| 2db173cd56 | |||
| d032103b7d | |||
| bcc488993d | |||
| aa0a97c245 | |||
| a39d4d4406 | |||
| 7c2417f5a9 | |||
| ea66e6c9c7 | |||
| f78bac6e6b | |||
| a0bfb97517 | |||
| ff9c4735e0 | |||
| 2f3e986d4a | |||
| 0a4bac30ac | |||
| ba2f6564ec | |||
| 754a79d21f | |||
| b52bbc6d9f | |||
| c67b1775a5 | |||
| 5940e11860 | |||
| fa8f949b45 | |||
| 6e17a2afc6 | |||
| 2375efa6b7 | |||
| 61fbebb13e | |||
| 05fb67e7f0 | |||
| 1a426f6301 | |||
| f56dfea838 | |||
| 6784b8f6be | |||
| 01fce64b2c | |||
| 79093fdcfb | |||
| 4ce8a12720 | |||
| cc2cc72482 | |||
| 12d527aad6 | |||
| 73b479a408 | |||
| 16183285b5 | |||
| 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 | |||
| ebc1d41d5c | |||
| fe5e3c9858 | |||
| 78b47384da | |||
| 38e6604f50 | |||
| 0e70b45e8a | |||
| 7312132767 | |||
| cb5b1bd2e2 | |||
| c9a402fa0a | |||
| 9f880395da | |||
| 585df726e7 | |||
| 1a3cde3a02 | |||
| bfd7ccd36c | |||
| ada30311ed | |||
| a8ca4f1cca | |||
| feb05a1df5 | |||
| 0204a7ddeb | |||
| 6ead20125d | |||
| 03e1e32af1 | |||
| c9cd6a08fc | |||
| daa78a036a | |||
| 3deb750441 | |||
| 88554ef952 | |||
| 110592f89e | |||
| 8b8da5584c | |||
| 8626fb2ca4 | |||
| 5952274c27 | |||
| 9c035e2be2 | |||
| cad3add223 | |||
| fc888629c3 | |||
| 6a21cc729e | |||
| 06370529c6 | |||
| 4260a6582c | |||
| a0f18dd237 | |||
| 00edc32aeb | |||
| cd6a788f77 | |||
| 9cf6a48b04 | |||
| 2c94616afa | |||
| c7be851c34 | |||
| fddfd0a769 | |||
| 074b49358b | |||
| a47b7649f0 | |||
| 7e08a85f73 | |||
| c7c5183356 | |||
| 7608d07255 | |||
| 1bc6798d76 | |||
| f6de7d1770 | |||
| 9dddd00858 | |||
| a5bf9c9db6 | |||
| a9c5c538b4 | |||
| a816faa0b9 | |||
| fbfacec6dc | |||
| b5eb8d1b74 | |||
| 0193f933ce | |||
| 5ad5eba58c | |||
| ef96cafcc8 | |||
| 6c9974b357 | |||
| ce5fc785e1 | |||
| 0019c086cf | |||
| 0eff7da46e | |||
| 2e6ee9dad2 | |||
| 81b109f981 | |||
| 73b16a7620 | |||
| a37bf101d2 | |||
| 5def6b7fbf | |||
| 892710faac | |||
| eb235cb101 | |||
| 04a1c63558 | |||
| 88f0c03c57 | |||
| 718aff5918 | |||
| 70311e6331 | |||
| 6a3da5aded | |||
| 35c3d00f17 | |||
| 3a5cb0de21 | |||
| f72d44d5e5 | |||
| a7b244502e | |||
| 72d5a51b44 | |||
| 12f46715c1 | |||
| 62f674b15d | |||
| b0a67744f5 | |||
| 431fb47498 | |||
| 04069e080a | |||
| dd1f690892 | |||
| 8b04deb608 | |||
| 531fb12eab | |||
| e666abefad | |||
| f1e9fd7cf8 | |||
| 9134812e32 | |||
| 4844576c3b | |||
| 4976f2e694 | |||
| d6a7b062f4 | |||
| 94c6ac7fa7 | |||
| e0f96001e2 | |||
| 3d89ba3b6f | |||
| 3afc8ed3d8 | |||
| 9e8bab5dec | |||
| 9d9fdd61e9 | |||
| b0df8f389a | |||
| 75c7f25a30 |
@@ -14,6 +14,15 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
||||
# Scanner — JSON array of CIDR ranges to scan
|
||||
SCANNER_RANGES=["192.168.1.0/24"]
|
||||
|
||||
# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
|
||||
# custom ports. Defaults below are overridable per-scan from the scan dialog.
|
||||
# SCANNER_HTTP_RANGES: JSON array of port specs, each a single port "N" or an
|
||||
# inclusive range "N-M" (1–65535, N <= M). Not CIDRs, not bare ints.
|
||||
# Example: SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
||||
SCANNER_HTTP_RANGES=[]
|
||||
SCANNER_HTTP_PROBE_ENABLED=false
|
||||
SCANNER_HTTP_VERIFY_TLS=false
|
||||
|
||||
# Status checker interval in seconds
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
|
||||
@@ -28,3 +37,9 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# Off by default. Set to a random secret to enable.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# 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:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke-and-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,14 +16,21 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- image: ghcr.io/pouzor/homelable-backend
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: "VITE_STANDALONE=true"
|
||||
- image: ghcr.io/pouzor/homelable-mcp
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
build_args: ""
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -55,8 +62,8 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Weekly on Monday
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -45,6 +45,8 @@ htmlcov/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.back
|
||||
*.db.back-*
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# Contributing to Homelable
|
||||
|
||||
Thanks for taking the time to contribute! This document covers everything you need to get started.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Ways to Contribute](#ways-to-contribute)
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Features](#suggesting-features)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Standards](#coding-standards)
|
||||
- [Testing](#testing)
|
||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
||||
- [Commit Message Format](#commit-message-format)
|
||||
|
||||
---
|
||||
|
||||
## Ways to Contribute
|
||||
|
||||
- Report bugs or unexpected behavior
|
||||
- Suggest new features or improvements
|
||||
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
|
||||
- Improve documentation
|
||||
- Add service signatures to `service_signatures.json`
|
||||
|
||||
---
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Before opening an issue, search existing ones to avoid duplicates.
|
||||
|
||||
When filing a bug, include:
|
||||
|
||||
- **Homelable version** (visible in the sidebar bottom-left)
|
||||
- **Deployment method** (Docker Compose, Proxmox LXC, source)
|
||||
- **Steps to reproduce**
|
||||
- **Expected vs actual behavior**
|
||||
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
|
||||
- **Browser console errors** if it's a UI issue
|
||||
|
||||
---
|
||||
|
||||
## Suggesting Features
|
||||
|
||||
Open an issue with the `enhancement` label. Describe:
|
||||
|
||||
- The problem you're trying to solve
|
||||
- Your proposed solution
|
||||
- Any alternatives you considered
|
||||
|
||||
For large changes, discuss first before writing code — it avoids wasted effort.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 20+** and **npm**
|
||||
- **Python 3.11–3.13** (3.14 not yet supported by all dependencies)
|
||||
- **nmap** installed on your system (required for scanner)
|
||||
- **Docker + Docker Compose** (optional, for full-stack testing)
|
||||
|
||||
### 1. Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Pouzor/homelable.git
|
||||
cd homelable
|
||||
```
|
||||
|
||||
### 2. Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.13 -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Copy and configure environment
|
||||
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
|
||||
|
||||
# Start the backend (auto-reloads on change)
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
API docs available at `http://localhost:8000/docs`.
|
||||
|
||||
### 3. Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Vite proxies `/api` to `localhost:8000` — the backend must be running.
|
||||
|
||||
### 4. Verify tooling
|
||||
|
||||
```bash
|
||||
./scripts/verify-tooling.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
homelable/
|
||||
├── frontend/src/
|
||||
│ ├── components/
|
||||
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
|
||||
│ │ ├── panels/ # Sidebar, detail panel, toolbar
|
||||
│ │ ├── modals/ # Add/edit node, scan config, pending devices
|
||||
│ │ └── ui/ # Shadcn/ui base components
|
||||
│ ├── stores/ # Zustand state (canvas, auth, scan)
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── types/ # TypeScript interfaces & enums
|
||||
│ ├── api/ # Axios client & typed endpoints
|
||||
│ └── utils/ # Layout, export, color helpers
|
||||
│
|
||||
├── backend/app/
|
||||
│ ├── api/routes/ # FastAPI route handlers
|
||||
│ ├── services/ # Scanner, status checker, canvas service
|
||||
│ ├── db/ # SQLAlchemy models, Alembic migrations
|
||||
│ ├── schemas/ # Pydantic request/response schemas
|
||||
│ └── core/ # Config, JWT, scheduler
|
||||
│
|
||||
├── docker/ # Nginx configs
|
||||
├── scripts/ # LXC bootstrap, dev helpers
|
||||
└── mcp/ # MCP server (AI integration)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### General
|
||||
|
||||
- No untested code merged — every feature or fix must include tests
|
||||
- Keep changes focused — one concern per PR
|
||||
|
||||
### Frontend (TypeScript + React)
|
||||
|
||||
- Strict TypeScript — no `any`, no type assertions unless truly necessary
|
||||
- React Flow node domain fields go in `node.data`, never on the node root
|
||||
- State management via Zustand stores — no prop drilling beyond 2 levels
|
||||
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
|
||||
- Run before committing:
|
||||
```bash
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
### Backend (Python + FastAPI)
|
||||
|
||||
- Python 3.11+ syntax
|
||||
- Pydantic v2 schemas for all request/response types
|
||||
- SQLAlchemy async sessions — never block the event loop
|
||||
- Scanner logic runs in a background thread — never in an async route directly
|
||||
- All schema changes via Alembic migrations — never modify tables directly
|
||||
- Run before committing:
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
ruff check .
|
||||
pytest
|
||||
```
|
||||
|
||||
### Design System
|
||||
|
||||
| Token | Value |
|
||||
|---|---|
|
||||
| Background | `#0d1117` |
|
||||
| Surface | `#161b22` |
|
||||
| Card | `#21262d` |
|
||||
| Accent cyan | `#00d4ff` |
|
||||
| Online | `#39d353` |
|
||||
| Offline | `#f85149` |
|
||||
| Pending | `#e3b341` |
|
||||
| Font (UI) | Inter |
|
||||
| Font (IPs/ports) | JetBrains Mono |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test # run all tests
|
||||
npm run test:coverage # with coverage report
|
||||
```
|
||||
|
||||
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
|
||||
|
||||
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
pytest # run all tests
|
||||
pytest -v tests/test_nodes.py # single file
|
||||
```
|
||||
|
||||
Test files live in `backend/tests/test_*.py`.
|
||||
|
||||
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
|
||||
|
||||
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
|
||||
|
||||
---
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. **Fork** the repo and create a branch from `main`:
|
||||
```bash
|
||||
git checkout -b feat/my-feature
|
||||
```
|
||||
|
||||
2. **Make your changes** — include tests.
|
||||
|
||||
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
|
||||
|
||||
4. **Open a PR** against `main`:
|
||||
- Use a clear title (see commit format below)
|
||||
- Describe what changed and why
|
||||
- Reference any related issues (`Closes #123`)
|
||||
- Include screenshots for UI changes
|
||||
|
||||
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
|
||||
|
||||
---
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>: <short description>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
| Type | When to use |
|
||||
|---|---|
|
||||
| `feat` | New feature |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation only |
|
||||
| `refactor` | Code change with no behavior change |
|
||||
| `test` | Adding or fixing tests |
|
||||
| `chore` | Build, deps, tooling |
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat: add logout button to sidebar
|
||||
fix: stop click propagation on pending device checkbox
|
||||
docs: add CONTRIBUTING.md
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## Questions?
|
||||
|
||||
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
|
||||
@@ -2,13 +2,14 @@ FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ .
|
||||
COPY VERSION /app/VERSION
|
||||
|
||||
# Create data directory (volume mount point)
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
@@ -12,6 +12,7 @@ COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ .
|
||||
COPY VERSION ../VERSION
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Remy Jardinet
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,13 +1,15 @@
|
||||
# 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.
|
||||
|
||||
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">
|
||||
<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/homelable3.png" alt="Homelable sidebar and scan" 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>
|
||||
|
||||
---
|
||||
@@ -37,6 +40,20 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
||||
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
||||
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
||||
|
||||
### Deep scan (custom ports)
|
||||
|
||||
By default the scanner only probes nmap's standard port set. To fingerprint services on non-standard ports, enable the deep scan via `.env` (all options are overridable per-scan from the scan dialog):
|
||||
|
||||
```env
|
||||
# JSON array of port specs — each entry is a single port "N" or an inclusive
|
||||
# range "N-M" (1–65535, N <= M). These are ports, not CIDRs or bare integers.
|
||||
SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
||||
SCANNER_HTTP_PROBE_ENABLED=true # send an HTTP probe to those ports for service ID
|
||||
SCANNER_HTTP_VERIFY_TLS=false # verify TLS certs on the HTTP probe
|
||||
```
|
||||
|
||||
The listed ports are appended to nmap's `-p` spec. Invalid entries (out-of-range, malformed, or reversed ranges) are silently skipped.
|
||||
|
||||
### macOS / root privileges
|
||||
|
||||
Some nmap scan types (SYN scan, OS detection) require root. If the scan fails with a permissions error, run it manually with sudo using the included script:
|
||||
@@ -74,6 +91,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 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 +148,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.
|
||||
|
||||
@@ -134,6 +237,12 @@ docker compose up -d mcp
|
||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||
```
|
||||
|
||||
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
||||
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
||||
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
||||
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
||||
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
||||
|
||||
**3. Configure your AI client:**
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -16,33 +17,54 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
async def load_canvas(
|
||||
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> CanvasStateResponse:
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_canvas(
|
||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
) -> dict[str, bool | str]:
|
||||
design_id = body.design_id
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
|
||||
db.add(new_design)
|
||||
await db.flush()
|
||||
design_id = new_design.id
|
||||
|
||||
incoming_node_ids = {n.id for n in body.nodes}
|
||||
incoming_edge_ids = {e.id for e in body.edges}
|
||||
|
||||
# Delete nodes removed from canvas
|
||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
||||
# Delete nodes removed from canvas (only within this design)
|
||||
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for node in existing_nodes:
|
||||
if node.id not in incoming_node_ids:
|
||||
await db.delete(node)
|
||||
|
||||
# Delete edges removed from canvas
|
||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
||||
# Delete edges removed from canvas (only within this design)
|
||||
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for edge in existing_edges:
|
||||
if edge.id not in incoming_edge_ids:
|
||||
await db.delete(edge)
|
||||
@@ -52,28 +74,33 @@ async def save_canvas(
|
||||
# Upsert nodes
|
||||
for node_data in body.nodes:
|
||||
db_node = await db.get(Node, node_data.id)
|
||||
payload = node_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_node:
|
||||
for field, value in node_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**node_data.model_dump()))
|
||||
db.add(Node(**payload))
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
db_edge = await db.get(Edge, edge_data.id)
|
||||
payload = edge_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_edge:
|
||||
for field, value in edge_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_edge, field, value)
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
db.add(Edge(**payload))
|
||||
|
||||
# Upsert viewport
|
||||
state = await db.get(CanvasState, 1)
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
||||
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[DesignResponse])
|
||||
async def list_designs(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[DesignResponse]:
|
||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
||||
return [DesignResponse.model_validate(d) for d in designs]
|
||||
|
||||
|
||||
@router.post("", response_model=DesignResponse, status_code=201)
|
||||
async def create_design(
|
||||
body: DesignCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
# Create empty canvas state for the new design
|
||||
db.add(CanvasState(design_id=design.id))
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.put("/{design_id}", response_model=DesignResponse)
|
||||
async def update_design(
|
||||
design_id: str,
|
||||
body: DesignUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
if body.name is not None:
|
||||
design.name = body.name
|
||||
if body.icon is not None:
|
||||
design.icon = body.icon
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.delete("/{design_id}", status_code=204)
|
||||
async def delete_design(
|
||||
design_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> None:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
# Count remaining designs — prevent deleting the last one
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, edges, nodes
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for n in nodes:
|
||||
await db.delete(n)
|
||||
await db.delete(design)
|
||||
await db.commit()
|
||||
@@ -4,7 +4,7 @@ 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 Edge
|
||||
from app.db.models import Design, Edge
|
||||
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,7 +18,14 @@ async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
||||
|
||||
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
|
||||
edge = Edge(**body.model_dump())
|
||||
data = body.model_dump()
|
||||
# Same reconciliation as nodes: clients omitting design_id (MCP write tools)
|
||||
# would create design_id=null edges that never render until a restart.
|
||||
# Fall back to the first design so the edge attaches to a canvas.
|
||||
if data.get("design_id") is None:
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
data["design_id"] = first_design.id if first_design else None
|
||||
edge = Edge(**data)
|
||||
db.add(edge)
|
||||
await db.commit()
|
||||
await db.refresh(edge)
|
||||
|
||||
@@ -2,12 +2,14 @@ import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -15,9 +17,30 @@ from app.schemas.nodes import NodeResponse
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LiveViewConfigResponse(BaseModel):
|
||||
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
|
||||
|
||||
enabled: bool
|
||||
key: str | None = None
|
||||
|
||||
|
||||
@router.get("/config", response_model=LiveViewConfigResponse)
|
||||
async def liveview_config(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> LiveViewConfigResponse:
|
||||
"""Authenticated: expose the configured live view key so the UI can build a
|
||||
ready-to-use share link (e.g. /view?key=...&design=<id>).
|
||||
|
||||
Only reachable by a logged-in user — the key is never exposed publicly.
|
||||
"""
|
||||
key = settings.liveview_key or None
|
||||
return LiveViewConfigResponse(enabled=bool(key), key=key)
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def liveview_canvas(
|
||||
key: str | None = Query(default=None),
|
||||
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CanvasStateResponse:
|
||||
"""Read-only public canvas endpoint.
|
||||
@@ -30,12 +53,20 @@ async def liveview_canvas(
|
||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 Node
|
||||
from app.db.models import Design, Node
|
||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,7 +18,15 @@ async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
||||
|
||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||
node = Node(**body.model_dump())
|
||||
data = body.model_dump()
|
||||
# Attach to a design so the node lands on a canvas. Clients that don't send a
|
||||
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
|
||||
# nodes that exist in the DB but never render in the UI until a container
|
||||
# restart reconciles them. Fall back to the first design, matching bulk-approve.
|
||||
if data.get("design_id") is None:
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
data["design_id"] = first_design.id if first_design else None
|
||||
node = Node(**data)
|
||||
db.add(node)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import ipaddress
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
@@ -11,14 +12,85 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.scanner import request_cancel, run_scan
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.zigbee_service import build_zigbee_properties
|
||||
from app.services.zwave_service import build_zwave_properties
|
||||
|
||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
|
||||
|
||||
|
||||
def _is_wireless(node_type: str | None) -> bool:
|
||||
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
||||
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
||||
|
||||
|
||||
def _wireless_properties(
|
||||
node_type: str | None,
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
lqi: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the right property rows for a mesh device (Z-Wave has no LQI)."""
|
||||
if node_type in _ZWAVE_TYPES:
|
||||
return build_zwave_properties(ieee, vendor, model)
|
||||
return build_zigbee_properties(ieee, vendor, model, lqi)
|
||||
|
||||
|
||||
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list carrying a device MAC address.
|
||||
|
||||
Shape matches the frontend ``NodeProperty`` type
|
||||
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
||||
showing it on the canvas card from the right panel. Returns an empty list
|
||||
when no MAC is known.
|
||||
"""
|
||||
if not mac:
|
||||
return []
|
||||
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
||||
|
||||
|
||||
def merge_mac_property(
|
||||
props: list[dict[str, Any]] | None, mac: str | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
||||
|
||||
Preserves any user-supplied properties (and an existing MAC row's
|
||||
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
||||
"""
|
||||
out = [dict(p) for p in (props or [])]
|
||||
if not mac or any(p.get("key") == "MAC" for p in out):
|
||||
return out
|
||||
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
||||
return out
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
# Target design for approved nodes. Falls back to the first design when
|
||||
# omitted (keeps older clients working), but the UI should send the active
|
||||
# design so approved devices land on the canvas the user is looking at.
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
def _check_port_ranges(v: list[str]) -> list[str]:
|
||||
for r in v:
|
||||
if not _valid_port_range(r.strip()):
|
||||
raise ValueError(f"Invalid port range: {r!r}")
|
||||
return v
|
||||
|
||||
|
||||
class ScanConfig(BaseModel):
|
||||
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
|
||||
|
||||
ranges: list[str]
|
||||
http_ranges: list[str] = []
|
||||
http_probe_enabled: bool = False
|
||||
verify_tls: bool = False
|
||||
|
||||
@field_validator("ranges")
|
||||
@classmethod
|
||||
@@ -30,28 +102,76 @@ class ScanConfig(BaseModel):
|
||||
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
||||
return v
|
||||
|
||||
@field_validator("http_ranges")
|
||||
@classmethod
|
||||
def validate_http_ranges(cls, v: list[str]) -> list[str]:
|
||||
return _check_port_ranges(v)
|
||||
|
||||
|
||||
class TriggerScanRequest(BaseModel):
|
||||
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
|
||||
|
||||
http_ranges: list[str] | None = None
|
||||
http_probe_enabled: bool | None = None
|
||||
verify_tls: bool | None = None
|
||||
|
||||
@field_validator("http_ranges")
|
||||
@classmethod
|
||||
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
|
||||
return None if v is None else _check_port_ranges(v)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
async def _background_scan(
|
||||
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await run_scan(ranges, db, run_id)
|
||||
try:
|
||||
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
|
||||
except Exception:
|
||||
logger.exception("Scan run %s failed unexpectedly", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
run.status = "failed"
|
||||
await db.commit()
|
||||
|
||||
|
||||
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
|
||||
"""Merge per-scan overrides over persisted settings defaults."""
|
||||
p = payload or TriggerScanRequest()
|
||||
return DeepScanOptions(
|
||||
http_ranges=(
|
||||
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
|
||||
),
|
||||
http_probe_enabled=(
|
||||
p.http_probe_enabled
|
||||
if p.http_probe_enabled is not None
|
||||
else settings.scanner_http_probe_enabled
|
||||
),
|
||||
verify_tls=(
|
||||
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/trigger", response_model=ScanRunResponse)
|
||||
async def trigger_scan(
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: TriggerScanRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
ranges = settings.scanner_ranges
|
||||
deep_scan = _resolve_deep_scan(payload)
|
||||
run = ScanRun(status="running", ranges=ranges)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_scan, run.id, ranges)
|
||||
background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
|
||||
return run
|
||||
|
||||
|
||||
@@ -71,13 +191,98 @@ async def stop_scan(
|
||||
if run.status != "running":
|
||||
raise HTTPException(status_code=409, detail="Scan is not running")
|
||||
request_cancel(run_id)
|
||||
# Flip status eagerly so the UI reflects the stop immediately, instead of
|
||||
# waiting for run_scan to reach its next cancellation checkpoint (which may
|
||||
# be blocked inside a long nmap call). run_scan converges to the same state.
|
||||
run.status = "cancelled"
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"stopping": True}
|
||||
|
||||
|
||||
def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
||||
"""Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
|
||||
present = [v for v in values if v is not None]
|
||||
if not present:
|
||||
return None
|
||||
return max(present) if newest else min(present)
|
||||
|
||||
|
||||
async def _canvas_correlation(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Correlate each device to existing canvas nodes by ``ieee_address`` or ``ip``.
|
||||
|
||||
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
||||
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
||||
(node counts are small for a homelab), so no N+1 per device.
|
||||
"""
|
||||
if not devices:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
Node.ip,
|
||||
Node.ieee_address,
|
||||
Node.design_id,
|
||||
Node.created_at,
|
||||
Node.last_scan,
|
||||
Node.updated_at,
|
||||
Node.last_seen,
|
||||
).where(Node.design_id.isnot(None))
|
||||
)
|
||||
).all()
|
||||
# Index matching nodes by ip and by ieee so a device can look up both.
|
||||
by_ip: dict[str, list[Any]] = {}
|
||||
by_ieee: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
if row.ip:
|
||||
by_ip.setdefault(row.ip, []).append(row)
|
||||
if row.ieee_address:
|
||||
by_ieee.setdefault(row.ieee_address, []).append(row)
|
||||
|
||||
info: dict[str, dict[str, Any]] = {}
|
||||
for d in devices:
|
||||
matched = []
|
||||
if d.ieee_address:
|
||||
matched += by_ieee.get(d.ieee_address, [])
|
||||
if d.ip:
|
||||
matched += by_ip.get(d.ip, [])
|
||||
# De-duplicate nodes matched by both ip and ieee.
|
||||
matched = list({id(m): m for m in matched}.values())
|
||||
designs = {m.design_id for m in matched}
|
||||
info[d.id] = {
|
||||
"canvas_count": len(designs),
|
||||
"node_created_at": _agg([m.created_at for m in matched], newest=False),
|
||||
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
|
||||
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
||||
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
async def _with_canvas_counts(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> list[PendingDevice]:
|
||||
"""Attach transient canvas count + linked-node timestamps for the response."""
|
||||
info = await _canvas_correlation(db, devices)
|
||||
for d in devices:
|
||||
meta = info.get(d.id, {})
|
||||
d.canvas_count = meta.get("canvas_count", 0)
|
||||
d.node_created_at = meta.get("node_created_at")
|
||||
d.node_last_scan = meta.get("node_last_scan")
|
||||
d.node_last_modified = meta.get("node_last_modified")
|
||||
d.node_last_seen = meta.get("node_last_seen")
|
||||
return devices
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
return list(result.scalars().all())
|
||||
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
||||
# stay listed so they keep showing with a canvas-presence badge.
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.delete("/pending", response_model=dict)
|
||||
@@ -85,18 +290,163 @@ async def clear_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
await db.delete(device)
|
||||
from sqlalchemy import delete as sa_delete
|
||||
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
await db.commit()
|
||||
return {"deleted": len(devices)}
|
||||
return {"deleted": result.rowcount}
|
||||
|
||||
|
||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||
return list(result.scalars().all())
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
async def bulk_approve_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Target the design the user is on; fall back to the first design.
|
||||
default_design_id = payload.design_id
|
||||
if default_design_id is None:
|
||||
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
|
||||
|
||||
# Accept every selected device that isn't user-hidden. We intentionally do NOT
|
||||
# filter on status == "pending": a device's status is global, but canvas
|
||||
# membership is per-design. A device approved onto another canvas (or whose
|
||||
# node was later deleted) must still be placeable on THIS design. Duplicates
|
||||
# are guarded per-design below, not by the global status flag.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
# What already sits on the target canvas, so we skip devices already placed
|
||||
# here (by ip or ieee_address) instead of creating duplicate nodes.
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
|
||||
)
|
||||
).all()
|
||||
placed_ips = {ip for ip, _ in existing if ip}
|
||||
placed_ieee = {ieee for _, ieee in existing if ieee}
|
||||
|
||||
created_nodes: list[Node] = []
|
||||
approved_devices: list[PendingDevice] = []
|
||||
for device in devices:
|
||||
already_here = (
|
||||
(device.ip is not None and device.ip in placed_ips)
|
||||
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
|
||||
)
|
||||
if already_here:
|
||||
continue
|
||||
device.status = "approved"
|
||||
node_type = device.suggested_type or "generic"
|
||||
is_wireless = _is_wireless(node_type)
|
||||
node = Node(
|
||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
mac=device.mac,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_wireless else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_wireless 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_wireless else ("ping" if device.ip else None),
|
||||
design_id=default_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
created_nodes.append(node)
|
||||
approved_devices.append(device)
|
||||
# Track within this batch so a duplicate selection (same ip/ieee) is not
|
||||
# placed twice on the same canvas.
|
||||
if device.ip:
|
||||
placed_ips.add(device.ip)
|
||||
if device.ieee_address:
|
||||
placed_ieee.add(device.ieee_address)
|
||||
await db.flush() # populates node.id from Python-side default before reading
|
||||
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
|
||||
node_ids = [n.id for n in created_nodes]
|
||||
approved_device_ids = [d.id for d in approved_devices]
|
||||
|
||||
all_edges: list[dict[str, str]] = []
|
||||
for device in approved_devices:
|
||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"approved": len(node_ids),
|
||||
"node_ids": node_ids,
|
||||
"device_ids": approved_device_ids,
|
||||
"edges_created": len(all_edges),
|
||||
"edges": all_edges,
|
||||
"skipped": len(payload.device_ids) - len(node_ids),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pending/bulk-hide", response_model=dict)
|
||||
async def bulk_hide_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 == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
device.status = "hidden"
|
||||
await db.commit()
|
||||
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)
|
||||
@@ -106,14 +456,138 @@ async def approve_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design
|
||||
node_design_id = node_data.design_id
|
||||
if node_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
if device:
|
||||
device.status = "approved"
|
||||
node = Node(**node_data.model_dump())
|
||||
db.add(node)
|
||||
await db.commit()
|
||||
return {"approved": True, "node_id": node.id}
|
||||
return {"approved": False}
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "pending":
|
||||
raise HTTPException(status_code=409, detail="Device already processed")
|
||||
device.status = "approved"
|
||||
wireless = _is_wireless(node_data.type)
|
||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||
# fall back to whatever the approve payload carried.
|
||||
_mac = device.mac or node_data.mac
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
mac=_mac,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if wireless else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if wireless else merge_mac_property(node_data.properties, _mac),
|
||||
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if wireless else node_data.check_target,
|
||||
design_id=node_design_id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
node_id = node.id
|
||||
|
||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||
|
||||
await db.commit()
|
||||
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")
|
||||
@@ -148,15 +622,35 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
return ScanConfig(ranges=settings.scanner_ranges)
|
||||
return ScanConfig(
|
||||
ranges=settings.scanner_ranges,
|
||||
http_ranges=settings.scanner_http_ranges,
|
||||
http_probe_enabled=settings.scanner_http_probe_enabled,
|
||||
verify_tls=settings.scanner_http_verify_tls,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ScanConfig)
|
||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||
previous = (
|
||||
settings.scanner_ranges,
|
||||
settings.scanner_http_ranges,
|
||||
settings.scanner_http_probe_enabled,
|
||||
settings.scanner_http_verify_tls,
|
||||
)
|
||||
settings.scanner_ranges = payload.ranges
|
||||
settings.scanner_http_ranges = payload.http_ranges
|
||||
settings.scanner_http_probe_enabled = payload.http_probe_enabled
|
||||
settings.scanner_http_verify_tls = payload.verify_tls
|
||||
try:
|
||||
settings.scanner_ranges = payload.ranges
|
||||
settings.save_overrides()
|
||||
return payload
|
||||
except Exception as exc:
|
||||
(
|
||||
settings.scanner_ranges,
|
||||
settings.scanner_http_ranges,
|
||||
settings.scanner_http_probe_enabled,
|
||||
settings.scanner_http_verify_tls,
|
||||
) = previous
|
||||
logger.error("Failed to save scan config: %s", exc)
|
||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"""App-level settings (status checker interval, etc.)."""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
interval_seconds: int
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = Field(default=300, ge=30)
|
||||
|
||||
|
||||
@router.get("", response_model=AppSettings)
|
||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
||||
return AppSettings(interval_seconds=settings.status_checker_interval)
|
||||
return AppSettings(
|
||||
interval_seconds=settings.status_checker_interval,
|
||||
service_check_enabled=settings.service_check_enabled,
|
||||
service_check_interval=settings.service_check_interval,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AppSettings)
|
||||
@@ -23,7 +30,13 @@ async def update_settings(
|
||||
) -> AppSettings:
|
||||
try:
|
||||
settings.status_checker_interval = payload.interval_seconds
|
||||
settings.service_check_enabled = payload.service_check_enabled
|
||||
settings.service_check_interval = payload.service_check_interval
|
||||
settings.save_overrides()
|
||||
# Apply the service-check schedule live.
|
||||
set_service_checks_enabled(payload.service_check_enabled)
|
||||
if payload.service_check_enabled:
|
||||
reschedule_service_checks(payload.service_check_interval)
|
||||
return payload
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
@@ -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
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
@@ -10,6 +11,12 @@ router = APIRouter()
|
||||
_connections: list[WebSocket] = []
|
||||
|
||||
|
||||
def _drop(websocket: WebSocket) -> None:
|
||||
"""Remove a connection if still present — idempotent, never raises."""
|
||||
with contextlib.suppress(ValueError):
|
||||
_connections.remove(websocket)
|
||||
|
||||
|
||||
@router.websocket("/ws/status")
|
||||
async def ws_status(websocket: WebSocket) -> None:
|
||||
# Accept first so we can send a close frame with a reason code
|
||||
@@ -33,7 +40,11 @@ async def ws_status(websocket: WebSocket) -> None:
|
||||
while True:
|
||||
await websocket.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
_connections.remove(websocket)
|
||||
pass
|
||||
finally:
|
||||
# Any error (disconnect or otherwise) must release the slot, else the
|
||||
# dead socket lingers in the broadcast pool.
|
||||
_drop(websocket)
|
||||
|
||||
|
||||
async def _broadcast(payload: str) -> None:
|
||||
@@ -41,7 +52,7 @@ async def _broadcast(payload: str) -> None:
|
||||
try:
|
||||
await conn.send_text(payload)
|
||||
except Exception:
|
||||
_connections.remove(conn)
|
||||
_drop(conn)
|
||||
|
||||
|
||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||
@@ -54,6 +65,15 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "service_status",
|
||||
"node_id": node_id,
|
||||
"services": services,
|
||||
"checked_at": checked_at,
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "scan_device_found",
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,284 @@
|
||||
"""FastAPI router for Z-Wave JS UI (zwavejs2mqtt) 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.zwave import (
|
||||
ZwaveCoordinatorOut,
|
||||
ZwaveEdgeOut,
|
||||
ZwaveImportPendingResponse,
|
||||
ZwaveImportRequest,
|
||||
ZwaveImportResponse,
|
||||
ZwaveNodeOut,
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
from app.services.zwave_service import (
|
||||
build_zwave_properties,
|
||||
fetch_zwave_network,
|
||||
merge_zwave_properties,
|
||||
test_zwave_connection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZwaveImportResponse)
|
||||
async def import_zwave_network(
|
||||
payload: ZwaveImportRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZwaveImportResponse:
|
||||
"""Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
|
||||
|
||||
Connects to the broker, publishes a ``getNodes`` request to the Z-Wave JS UI
|
||||
gateway, and waits for the response. Devices are returned as typed homelable
|
||||
nodes with a coordinator → router → end-device hierarchy.
|
||||
"""
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_zwave_network(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
prefix=payload.prefix,
|
||||
gateway_name=payload.gateway_name,
|
||||
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 Z-Wave import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
|
||||
|
||||
nodes = [ZwaveNodeOut(**n) for n in nodes_raw]
|
||||
edges = [ZwaveEdgeOut(**e) for e in edges_raw]
|
||||
return ZwaveImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
async def import_zwave_to_pending(
|
||||
payload: ZwaveImportRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Queue a Z-Wave pending import as a background scan run (kind=zwave)."""
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="zwave",
|
||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_zwave_import, run.id, payload)
|
||||
return run
|
||||
|
||||
|
||||
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_zwave_network(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
prefix=payload.prefix,
|
||||
gateway_name=payload.gateway_name,
|
||||
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("Z-Wave 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]],
|
||||
) -> ZwaveImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
|
||||
identity. All zwave-source links are wiped and re-inserted from the new map.
|
||||
"""
|
||||
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: ZwaveCoordinatorOut | 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_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
||||
|
||||
if n.get("type") == "zwave_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_zwave_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
coordinator_out = ZwaveCoordinatorOut(
|
||||
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 "zwave_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 = ZwaveCoordinatorOut(
|
||||
id=node.id, label=label, ieee_address=ieee
|
||||
)
|
||||
continue
|
||||
|
||||
# Already approved as a canvas Node → refresh props, skip pending row.
|
||||
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_zwave_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="zwave",
|
||||
)
|
||||
)
|
||||
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 pending.status == "approved":
|
||||
# Approved earlier but the canvas Node is gone (deleted) — revive
|
||||
# to "pending" so it reappears in the list instead of vanishing.
|
||||
pending.status = "pending"
|
||||
elif pending.status == "hidden":
|
||||
pass
|
||||
pending_updated += 1
|
||||
|
||||
# Replace all zwave-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
|
||||
)
|
||||
|
||||
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="zwave",
|
||||
)
|
||||
)
|
||||
links_recorded += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ZwaveImportPendingResponse(
|
||||
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=ZwaveTestConnectionResponse)
|
||||
async def test_connection_endpoint(
|
||||
payload: ZwaveTestConnectionRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZwaveTestConnectionResponse:
|
||||
"""Quick MQTT ping to validate broker connection before importing."""
|
||||
try:
|
||||
await test_zwave_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 ZwaveTestConnectionResponse(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 ZwaveTestConnectionResponse(connected=False, message=str(exc))
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
@@ -7,6 +7,17 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _read_version() -> str:
|
||||
for candidate in [
|
||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||
Path("/app/VERSION"), # Docker image
|
||||
]:
|
||||
if candidate.exists():
|
||||
return candidate.read_text().strip()
|
||||
return "unknown"
|
||||
|
||||
APP_VERSION = _read_version()
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
@@ -37,9 +48,19 @@ class Settings(BaseSettings):
|
||||
# Scanner
|
||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||
|
||||
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
||||
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||
scanner_http_ranges: list[str] = []
|
||||
scanner_http_probe_enabled: bool = False
|
||||
scanner_http_verify_tls: bool = False
|
||||
|
||||
# Status checker
|
||||
status_checker_interval: int = 60
|
||||
|
||||
# Per-service status checker (independent of node checks). Off by default.
|
||||
service_check_enabled: bool = False
|
||||
service_check_interval: int = 300
|
||||
|
||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||
# Used by the MCP server to authenticate against the backend without a user password.
|
||||
# Leave empty to disable MCP service key auth.
|
||||
@@ -50,6 +71,11 @@ class Settings(BaseSettings):
|
||||
# Leave unset (or empty) to keep the feature disabled (default).
|
||||
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:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
@@ -61,6 +87,16 @@ class Settings(BaseSettings):
|
||||
self.scanner_ranges = data["scanner_ranges"]
|
||||
if "status_checker_interval" in data:
|
||||
self.status_checker_interval = int(data["status_checker_interval"])
|
||||
if "service_check_enabled" in data:
|
||||
self.service_check_enabled = bool(data["service_check_enabled"])
|
||||
if "service_check_interval" in data:
|
||||
self.service_check_interval = int(data["service_check_interval"])
|
||||
if "scanner_http_ranges" in data:
|
||||
self.scanner_http_ranges = list(data["scanner_http_ranges"])
|
||||
if "scanner_http_probe_enabled" in data:
|
||||
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
||||
if "scanner_http_verify_tls" in data:
|
||||
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -70,6 +106,11 @@ class Settings(BaseSettings):
|
||||
self._override_path().write_text(json.dumps({
|
||||
"scanner_ranges": self.scanner_ranges,
|
||||
"status_checker_interval": self.status_checker_interval,
|
||||
"service_check_enabled": self.service_check_enabled,
|
||||
"service_check_interval": self.service_check_interval,
|
||||
"scanner_http_ranges": self.scanner_http_ranges,
|
||||
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
||||
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from sqlalchemy import select
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal
|
||||
from app.db.models import Node
|
||||
from app.services.status_checker import check_node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,6 +73,50 @@ async def _run_status_checks() -> None:
|
||||
])
|
||||
|
||||
|
||||
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||
"""Pick the address to probe services on: first IP, else hostname."""
|
||||
if ip:
|
||||
first = ip.split(",")[0].strip()
|
||||
if first:
|
||||
return first
|
||||
return hostname or None
|
||||
|
||||
|
||||
async def _run_service_checks() -> None:
|
||||
"""Check every service of every node and broadcast per-service results."""
|
||||
if not settings.service_check_enabled:
|
||||
return
|
||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
checkable = [
|
||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||
for n in nodes
|
||||
if n.services
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for node_id, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||
except Exception as exc:
|
||||
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
||||
|
||||
|
||||
def _add_service_check_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_service_checks,
|
||||
"interval",
|
||||
seconds=settings.service_check_interval,
|
||||
id="service_checks",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
@@ -89,6 +133,8 @@ def start_scheduler() -> None:
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
if settings.service_check_enabled:
|
||||
_add_service_check_job()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
@@ -104,6 +150,31 @@ def reschedule_status_checks(interval_seconds: int) -> None:
|
||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def reschedule_service_checks(interval_seconds: int) -> None:
|
||||
"""Update the service-check interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 30:
|
||||
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("service_checks"):
|
||||
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_service_checks_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the service-check job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("service_checks")
|
||||
if enabled and not job:
|
||||
_add_service_check_job()
|
||||
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("service_checks")
|
||||
logger.info("Service checks disabled")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
return False
|
||||
try:
|
||||
return bool(pwd_context.verify(plain, hashed))
|
||||
except ValueError:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
[
|
||||
{
|
||||
"vendor": "Proxmox / QEMU / KVM",
|
||||
"type": "vm",
|
||||
"prefixes": ["52:54:00", "bc:24:11"]
|
||||
},
|
||||
{
|
||||
"vendor": "VMware",
|
||||
"type": "vm",
|
||||
"prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"]
|
||||
},
|
||||
{
|
||||
"vendor": "VirtualBox",
|
||||
"type": "vm",
|
||||
"prefixes": ["08:00:27"]
|
||||
},
|
||||
{
|
||||
"vendor": "Microsoft Hyper-V",
|
||||
"type": "vm",
|
||||
"prefixes": ["00:15:5d"]
|
||||
},
|
||||
{
|
||||
"vendor": "Xen",
|
||||
"type": "vm",
|
||||
"prefixes": ["00:16:3e"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "MikroTik",
|
||||
"type": "router",
|
||||
"prefixes": [
|
||||
"00:0c:42",
|
||||
"08:55:31",
|
||||
"18:fd:74",
|
||||
"2c:c8:1b",
|
||||
"48:8f:5a",
|
||||
"4c:5e:0c",
|
||||
"64:d1:54",
|
||||
"6c:3b:6b",
|
||||
"74:4d:28",
|
||||
"b8:69:f4",
|
||||
"c4:ad:34",
|
||||
"cc:2d:e0",
|
||||
"d4:ca:6d",
|
||||
"dc:2c:6e",
|
||||
"e4:8d:8c"
|
||||
]
|
||||
},
|
||||
{
|
||||
"vendor": "Ubiquiti",
|
||||
"type": "ap",
|
||||
"prefixes": [
|
||||
"00:15:6d",
|
||||
"00:27:22",
|
||||
"04:18:d6",
|
||||
"24:5a:4c",
|
||||
"24:a4:3c",
|
||||
"44:d9:e7",
|
||||
"68:72:51",
|
||||
"68:d7:9a",
|
||||
"74:83:c2",
|
||||
"78:8a:20",
|
||||
"78:45:58",
|
||||
"80:2a:a8",
|
||||
"94:2a:6f",
|
||||
"9c:05:d6",
|
||||
"b4:fb:e4",
|
||||
"dc:9f:db",
|
||||
"e0:63:da",
|
||||
"f0:9f:c2",
|
||||
"fc:ec:da"
|
||||
]
|
||||
},
|
||||
{
|
||||
"vendor": "Ruckus Wireless",
|
||||
"type": "ap",
|
||||
"prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"]
|
||||
},
|
||||
{
|
||||
"vendor": "Aruba Networks (HPE)",
|
||||
"type": "ap",
|
||||
"prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Cisco Systems",
|
||||
"type": "switch",
|
||||
"prefixes": [
|
||||
"00:00:0c",
|
||||
"00:1b:0d",
|
||||
"00:1c:f6",
|
||||
"00:1e:13",
|
||||
"00:23:04",
|
||||
"00:24:13",
|
||||
"00:25:45",
|
||||
"00:50:0b",
|
||||
"b0:00:b4",
|
||||
"b8:38:61",
|
||||
"f8:c0:01"
|
||||
]
|
||||
},
|
||||
{
|
||||
"vendor": "Juniper Networks",
|
||||
"type": "switch",
|
||||
"prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"]
|
||||
},
|
||||
{
|
||||
"vendor": "Zyxel",
|
||||
"type": "switch",
|
||||
"prefixes": ["00:13:49", "60:31:97", "ec:43:f6"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Netgear",
|
||||
"type": "router",
|
||||
"prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"]
|
||||
},
|
||||
{
|
||||
"vendor": "TP-Link",
|
||||
"type": "router",
|
||||
"prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Synology",
|
||||
"type": "nas",
|
||||
"prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"]
|
||||
},
|
||||
{
|
||||
"vendor": "QNAP Systems",
|
||||
"type": "nas",
|
||||
"prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"]
|
||||
},
|
||||
{
|
||||
"vendor": "Asustor",
|
||||
"type": "nas",
|
||||
"prefixes": ["e8:9c:25"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Hikvision",
|
||||
"type": "camera",
|
||||
"prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"]
|
||||
},
|
||||
{
|
||||
"vendor": "Dahua / Amcrest",
|
||||
"type": "camera",
|
||||
"prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"]
|
||||
},
|
||||
{
|
||||
"vendor": "Reolink",
|
||||
"type": "camera",
|
||||
"prefixes": ["ec:71:db"]
|
||||
},
|
||||
{
|
||||
"vendor": "Axis Communications",
|
||||
"type": "camera",
|
||||
"prefixes": ["00:40:8c", "ac:cc:8e"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Raspberry Pi Foundation",
|
||||
"type": "server",
|
||||
"prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"]
|
||||
},
|
||||
{
|
||||
"vendor": "Dell",
|
||||
"type": "server",
|
||||
"prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"]
|
||||
},
|
||||
{
|
||||
"vendor": "Supermicro",
|
||||
"type": "server",
|
||||
"prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"]
|
||||
},
|
||||
|
||||
{
|
||||
"vendor": "Shelly",
|
||||
"type": "iot",
|
||||
"prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"]
|
||||
},
|
||||
{
|
||||
"vendor": "Espressif (ESP8266 / ESP32)",
|
||||
"type": "iot",
|
||||
"prefixes": [
|
||||
"24:62:ab",
|
||||
"30:ae:a4",
|
||||
"3c:71:bf",
|
||||
"8c:aa:b5",
|
||||
"a0:20:a6",
|
||||
"ac:67:b2",
|
||||
"b4:e6:2d",
|
||||
"cc:50:e3"
|
||||
]
|
||||
},
|
||||
{
|
||||
"vendor": "Sonoff / ITEAD",
|
||||
"type": "iot",
|
||||
"prefixes": ["dc:4f:22", "e8:db:84"]
|
||||
},
|
||||
{
|
||||
"vendor": "TP-Link Tapo / Kasa",
|
||||
"type": "iot",
|
||||
"prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"]
|
||||
},
|
||||
{
|
||||
"vendor": "Philips Hue",
|
||||
"type": "iot",
|
||||
"prefixes": ["00:17:88", "ec:b5:fa"]
|
||||
},
|
||||
{
|
||||
"vendor": "IKEA Tradfri",
|
||||
"type": "iot",
|
||||
"prefixes": ["00:21:2e", "34:13:e8"]
|
||||
},
|
||||
{
|
||||
"vendor": "Tuya / Smart Life",
|
||||
"type": "iot",
|
||||
"prefixes": ["68:57:2d", "d8:f1:5b"]
|
||||
}
|
||||
]
|
||||
@@ -142,5 +142,69 @@
|
||||
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
||||
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
|
||||
]
|
||||
|
||||
@@ -1,12 +1,35 @@
|
||||
import json as _json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
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 app.core.config import settings
|
||||
from app.core.config import APP_VERSION, settings
|
||||
|
||||
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
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -23,7 +46,22 @@ class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def _backup_db() -> None:
|
||||
db_path = Path(settings.sqlite_path)
|
||||
if not db_path.exists():
|
||||
return
|
||||
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
|
||||
if backup_path.exists():
|
||||
return
|
||||
try:
|
||||
shutil.copy2(db_path, backup_path)
|
||||
logger.info("DB backup created: %s", backup_path.name)
|
||||
except OSError:
|
||||
logger.warning("Could not create DB backup at %s", backup_path)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
_backup_db()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
@@ -53,6 +91,8 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||
with suppress(OperationalError):
|
||||
@@ -61,6 +101,201 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
||||
with suppress(OperationalError):
|
||||
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):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
||||
with suppress(OperationalError):
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for r in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||
if cpu_count is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||
if ram_gb is not None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(_json.dumps(props), node_id),
|
||||
)
|
||||
# Inventory timestamp: last time a scan observed this node (idempotent)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME")
|
||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
||||
|
||||
@@ -16,12 +16,24 @@ def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Design(Base):
|
||||
__tablename__ = "designs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class Node(Base):
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
ip: Mapped[str | None] = mapped_column(String)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
@@ -42,14 +54,17 @@ class Node(Base):
|
||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
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_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
||||
|
||||
@@ -60,6 +75,7 @@ class Edge(Base):
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||
label: Mapped[str | None] = mapped_column(String)
|
||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||
@@ -69,22 +85,27 @@ class Edge(Base):
|
||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||
source_handle: Mapped[str | None] = mapped_column(String)
|
||||
target_handle: Mapped[str | None] = mapped_column(String)
|
||||
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class CanvasState(Base):
|
||||
__tablename__ = "canvas_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingDevice(Base):
|
||||
__tablename__ = "pending_devices"
|
||||
# Permit the plain (non-Mapped[]) annotations on the transient request-only
|
||||
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
|
||||
__allow_unmapped__ = True
|
||||
|
||||
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)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
@@ -92,6 +113,41 @@ class PendingDevice(Base):
|
||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="pending")
|
||||
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)
|
||||
|
||||
# Transient (not persisted): populated per-request by the scan routes to report
|
||||
# how many canvases this device already appears on. Not a mapped column.
|
||||
canvas_count: int = 0
|
||||
# Transient (not persisted): timestamps from the linked canvas node(s),
|
||||
# correlated by ip / ieee_address. None when the device is not on any canvas.
|
||||
node_created_at: datetime | None = None
|
||||
node_last_scan: datetime | None = None
|
||||
node_last_modified: datetime | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -100,6 +156,7 @@ class ScanRun(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
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)
|
||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, edges, liveview, nodes, scan, status
|
||||
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave
|
||||
from app.api.routes import settings as settings_routes
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||
@@ -35,7 +35,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
app = FastAPI(
|
||||
title="Homelable API",
|
||||
version="1.8.3",
|
||||
version="1.9.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -51,10 +51,14 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
||||
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(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -29,6 +29,8 @@ class NodeSave(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
show_port_numbers: bool = False
|
||||
properties: list[Any] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int = 1
|
||||
@@ -49,6 +51,7 @@ class EdgeSave(BaseModel):
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
@@ -60,9 +63,12 @@ class CanvasSaveRequest(BaseModel):
|
||||
nodes: list[NodeSave] = []
|
||||
edges: list[EdgeSave] = []
|
||||
viewport: dict[str, Any] = {}
|
||||
custom_style: dict[str, Any] | None = None
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class CanvasStateResponse(BaseModel):
|
||||
nodes: list[NodeResponse]
|
||||
edges: list[EdgeResponse]
|
||||
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}
|
||||
@@ -17,6 +17,7 @@ class EdgeBase(BaseModel):
|
||||
animated: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
@@ -25,7 +26,7 @@ class EdgeBase(BaseModel):
|
||||
|
||||
|
||||
class EdgeCreate(EdgeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class EdgeUpdate(BaseModel):
|
||||
@@ -38,6 +39,7 @@ class EdgeUpdate(BaseModel):
|
||||
animated: str | None = None
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
|
||||
@field_validator('animated', mode='before')
|
||||
@classmethod
|
||||
@@ -49,6 +51,7 @@ class EdgeUpdate(BaseModel):
|
||||
|
||||
class EdgeResponse(EdgeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -27,13 +27,15 @@ class NodeBase(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
show_port_numbers: bool = False
|
||||
properties: list[dict[str, Any]] = []
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int = 1
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
@@ -59,6 +61,8 @@ class NodeUpdate(BaseModel):
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
show_port_numbers: bool | None = None
|
||||
properties: list[dict[str, Any]] | None = None
|
||||
width: float | None = None
|
||||
height: float | None = None
|
||||
bottom_handles: int | None = None
|
||||
@@ -66,7 +70,10 @@ class NodeUpdate(BaseModel):
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
ieee_address: str | None = None
|
||||
last_seen: datetime | None = None
|
||||
last_scan: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -6,7 +6,7 @@ from pydantic import BaseModel
|
||||
|
||||
class PendingDeviceResponse(BaseModel):
|
||||
id: str
|
||||
ip: str
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
hostname: str | None
|
||||
os: str | None
|
||||
@@ -14,7 +14,23 @@ class PendingDeviceResponse(BaseModel):
|
||||
suggested_type: str | None
|
||||
status: str
|
||||
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
|
||||
# Number of distinct canvases (designs) this device already appears on,
|
||||
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
||||
canvas_count: int = 0
|
||||
# Timestamps from the linked canvas node(s), correlated by ip / ieee_address.
|
||||
# Null when the device is not on any canvas yet. Aggregated across matches:
|
||||
# created_at = oldest; last_scan / last_modified / last_seen = newest.
|
||||
node_created_at: datetime | None = None
|
||||
node_last_scan: datetime | None = None
|
||||
node_last_modified: datetime | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -22,6 +38,7 @@ class PendingDeviceResponse(BaseModel):
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
kind: str = "ip"
|
||||
ranges: list[str]
|
||||
devices_found: int
|
||||
started_at: datetime
|
||||
|
||||
@@ -4,6 +4,6 @@ def normalize_animated(v: object) -> str:
|
||||
return 'snake'
|
||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
||||
return 'none'
|
||||
if v in ('snake', 'flow'):
|
||||
if v in ('snake', 'flow', 'basic'):
|
||||
return str(v)
|
||||
return 'none'
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Pydantic v2 schemas for Z-Wave JS UI (zwavejs2mqtt) import."""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ZwaveImportRequest(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)")
|
||||
prefix: str = Field("zwave", description="Z-Wave JS UI MQTT prefix")
|
||||
gateway_name: str = Field("zwavejs2mqtt", description="Z-Wave JS UI gateway name")
|
||||
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) -> "ZwaveImportRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZwaveTestConnectionRequest(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) -> "ZwaveTestConnectionRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZwaveNodeOut(BaseModel):
|
||||
"""A homelable-ready node representation of a Z-Wave device."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
type: str # zwave_coordinator | zwave_router | zwave_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 ZwaveEdgeOut(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class ZwaveImportResponse(BaseModel):
|
||||
nodes: list[ZwaveNodeOut]
|
||||
edges: list[ZwaveEdgeOut]
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZwaveTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ZwaveCoordinatorOut(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
ieee_address: str
|
||||
|
||||
|
||||
class ZwaveImportPendingResponse(BaseModel):
|
||||
"""Result of importing a Z-Wave network into the pending section."""
|
||||
|
||||
pending_created: int
|
||||
pending_updated: int
|
||||
coordinator: ZwaveCoordinatorOut | None = None
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||
_OUI_MAP: dict[str, str] | None = None
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -26,25 +27,124 @@ def _load() -> list[dict[str, Any]]:
|
||||
return _SIGNATURES
|
||||
|
||||
|
||||
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
||||
"""Return the first signature matching port+protocol, optionally banner."""
|
||||
for sig in _load():
|
||||
if sig["port"] != port or sig["protocol"] != protocol:
|
||||
continue
|
||||
if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
|
||||
continue
|
||||
return sig
|
||||
return None
|
||||
def _load_oui() -> dict[str, str]:
|
||||
"""Load OUI database and flatten to {prefix: node_type}."""
|
||||
global _OUI_MAP
|
||||
if _OUI_MAP is None:
|
||||
with _LOCK:
|
||||
if _OUI_MAP is None:
|
||||
path = Path(__file__).parent.parent / "data" / "oui_database.json"
|
||||
try:
|
||||
with open(path) as f:
|
||||
entries = json.load(f)
|
||||
except FileNotFoundError as err:
|
||||
raise FileNotFoundError(
|
||||
f"oui_database.json not found at {path}. "
|
||||
"This file should be bundled with the application."
|
||||
) from err
|
||||
_OUI_MAP = {
|
||||
prefix.lower(): entry["type"]
|
||||
for entry in entries
|
||||
for prefix in entry["prefixes"]
|
||||
}
|
||||
return _OUI_MAP
|
||||
|
||||
|
||||
def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
|
||||
"""True when the signature's http_regex matches the probe's title/headers."""
|
||||
rx = sig.get("http_regex")
|
||||
if not rx or not http_signals:
|
||||
return False
|
||||
headers = http_signals.get("headers") or {}
|
||||
haystack = " ".join(
|
||||
s for s in (
|
||||
http_signals.get("title"),
|
||||
headers.get("Server"),
|
||||
headers.get("X-Powered-By"),
|
||||
) if s
|
||||
)
|
||||
return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
|
||||
|
||||
|
||||
def _service_tier(
|
||||
sig: dict[str, Any],
|
||||
port: int,
|
||||
protocol: str,
|
||||
banner: str | None,
|
||||
http_signals: dict[str, Any] | None,
|
||||
) -> int | None:
|
||||
"""
|
||||
Given a list of {port, protocol, banner?} dicts, return matched services.
|
||||
Unknown ports are included as unknown_service.
|
||||
Rank how well a signature matches (lower = stronger). None = not a match.
|
||||
|
||||
Tier 1: port match + http_regex confirmed
|
||||
Tier 2: port match + banner_regex confirmed
|
||||
Tier 3: port-agnostic (port: null) + http_regex confirmed
|
||||
Tier 4: port match only (no regex, or http_regex with probe disabled)
|
||||
|
||||
When http_signals is None (probe not run) an http_regex entry degrades to
|
||||
a port-only match — identical to pre-probe behaviour, no regression.
|
||||
When http_signals is provided, http_regex is strict: a miss disqualifies.
|
||||
"""
|
||||
probe_ran = http_signals is not None
|
||||
has_http = bool(sig.get("http_regex"))
|
||||
|
||||
# Port-agnostic entries (port: null) match purely on HTTP signals.
|
||||
if sig.get("port") is None:
|
||||
if has_http and _http_regex_hit(sig, http_signals):
|
||||
return 3
|
||||
return None
|
||||
|
||||
if sig["port"] != port or sig["protocol"] != protocol:
|
||||
return None
|
||||
|
||||
# http_regex is authoritative once a probe has run.
|
||||
if has_http and probe_ran:
|
||||
return 1 if _http_regex_hit(sig, http_signals) else None
|
||||
|
||||
if sig.get("banner_regex"):
|
||||
if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
|
||||
return 2
|
||||
return None
|
||||
|
||||
# No regex constraint (or http_regex but probe disabled) → port-only guess.
|
||||
return 4
|
||||
|
||||
|
||||
def match_service(
|
||||
port: int,
|
||||
protocol: str,
|
||||
banner: str | None = None,
|
||||
http_signals: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the best signature for a port, walking tiers most-specific first."""
|
||||
best: dict[str, Any] | None = None
|
||||
best_tier = 99
|
||||
for sig in _load():
|
||||
tier = _service_tier(sig, port, protocol, banner, http_signals)
|
||||
if tier is not None and tier < best_tier:
|
||||
best, best_tier = sig, tier
|
||||
if best_tier == 1:
|
||||
break # strongest possible — stop early
|
||||
return best
|
||||
|
||||
|
||||
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
||||
"""Back-compat alias: match without HTTP-probe signals."""
|
||||
return match_service(port, protocol, banner)
|
||||
|
||||
|
||||
def fingerprint_ports(
|
||||
open_ports: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Given a list of {port, protocol, banner?, http_signals?} dicts, return
|
||||
matched services. Unknown ports are included as unknown_service.
|
||||
"""
|
||||
results = []
|
||||
for p in open_ports:
|
||||
sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
|
||||
sig = match_service(
|
||||
p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
|
||||
)
|
||||
if sig:
|
||||
results.append({
|
||||
"port": p["port"],
|
||||
@@ -65,55 +165,12 @@ def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
# Known OUI prefixes — lowercase, colon-separated, first 3 octets
|
||||
_MAC_OUI_TYPES: dict[str, str] = {
|
||||
# Hypervisors / VMs
|
||||
"52:54:00": "vm", # QEMU/KVM (Proxmox VMs)
|
||||
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, 7.3+)
|
||||
"00:50:56": "vm", # VMware
|
||||
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||
"08:00:27": "vm", # VirtualBox
|
||||
"00:15:5d": "vm", # Hyper-V
|
||||
# Shelly
|
||||
"34:94:54": "iot",
|
||||
"84:f3:eb": "iot",
|
||||
"ec:fa:bc": "iot",
|
||||
"30:c6:f7": "iot",
|
||||
# Espressif (ESP8266 / ESP32 — used by Sonoff, many DIY IoT)
|
||||
"a0:20:a6": "iot",
|
||||
"24:62:ab": "iot",
|
||||
"30:ae:a4": "iot",
|
||||
"cc:50:e3": "iot",
|
||||
"ac:67:b2": "iot",
|
||||
"b4:e6:2d": "iot",
|
||||
"3c:71:bf": "iot",
|
||||
"8c:aa:b5": "iot",
|
||||
# Sonoff / ITEAD
|
||||
"dc:4f:22": "iot",
|
||||
"e8:db:84": "iot",
|
||||
# Tapo / TP-Link smart home
|
||||
"b0:a7:b9": "iot",
|
||||
"50:c7:bf": "iot",
|
||||
"1c:3b:f3": "iot",
|
||||
"10:27:f5": "iot",
|
||||
# Philips Hue
|
||||
"00:17:88": "iot",
|
||||
"ec:b5:fa": "iot",
|
||||
# IKEA Tradfri
|
||||
"34:13:e8": "iot",
|
||||
"00:21:2e": "iot",
|
||||
# Tuya / Smart Life (widely used chip in many brands)
|
||||
"d8:f1:5b": "iot",
|
||||
"68:57:2d": "iot",
|
||||
}
|
||||
|
||||
|
||||
def suggest_type_from_mac(mac: str | None) -> str | None:
|
||||
"""Return a suggested node type from MAC OUI, or None if unknown."""
|
||||
if not mac:
|
||||
return None
|
||||
prefix = mac.lower()[:8]
|
||||
return _MAC_OUI_TYPES.get(prefix)
|
||||
return _load_oui().get(prefix)
|
||||
|
||||
|
||||
_PORT_TYPE_HINTS: dict[int, str] = {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""HTTP probe: GET a discovered port and extract identifying signals.
|
||||
|
||||
Used by the optional deep-scan mode to confirm what service sits behind an
|
||||
open port, regardless of port number. Returns the page <title> plus a small
|
||||
set of identifying response headers, which fingerprint.match_service() then
|
||||
matches against signature http_regex fields.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Headers that commonly carry the application name.
|
||||
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
|
||||
# Cap how much body we read when hunting for <title> — avoids large downloads.
|
||||
_MAX_BODY_BYTES = 64 * 1024
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
_PROBE_TIMEOUT = 3.0
|
||||
# Ports we never bother probing over HTTP (not web services).
|
||||
_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
|
||||
|
||||
|
||||
def _extract_title(body: str) -> str | None:
|
||||
m = _TITLE_RE.search(body)
|
||||
if not m:
|
||||
return None
|
||||
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
return title or None
|
||||
|
||||
|
||||
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
except (httpx.HTTPError, OSError):
|
||||
return None
|
||||
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
||||
body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
|
||||
title = _extract_title(body)
|
||||
if not title and not headers:
|
||||
return None
|
||||
return {"title": title, "headers": headers}
|
||||
|
||||
|
||||
async def probe_port(
|
||||
ip: str, port: int, verify_tls: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
GET https:// then http:// for a port and return {title, headers} or None.
|
||||
|
||||
None means the port did not answer HTTP or yielded no usable signal.
|
||||
"""
|
||||
if port in _NON_HTTP_PORTS:
|
||||
return None
|
||||
async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
|
||||
for scheme in ("https", "http"):
|
||||
result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
|
||||
if result is not None:
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
async def probe_open_ports(
|
||||
ip: str,
|
||||
open_ports: list[dict[str, Any]],
|
||||
verify_tls: bool = False,
|
||||
concurrency: int = 50,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Probe every open port for HTTP signals (option 2: probe all, match after).
|
||||
|
||||
Returns the same port dicts, each enriched with an http_signals key
|
||||
(None when the port gave no HTTP signal).
|
||||
"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(p: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
signals = await probe_port(ip, p["port"], verify_tls)
|
||||
return {**p, "http_signals": signals}
|
||||
|
||||
return await asyncio.gather(*(_one(p) for p in open_ports))
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
|
||||
|
||||
Holds the credential-safe error sanitizer, the TLS context builder, and a
|
||||
generic request/response round-trip over MQTT used by gateway-style APIs
|
||||
(publish a request topic, wait for a single response topic message).
|
||||
"""
|
||||
|
||||
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]
|
||||
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are 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
|
||||
|
||||
|
||||
async def request_response(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
request_topic: str,
|
||||
response_topic: str,
|
||||
request_payload: dict[str, Any],
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float = _RESPONSE_TIMEOUT,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
||||
JSON message received on ``response_topic`` as a dict.
|
||||
|
||||
Raises:
|
||||
ImportError: if aiomqtt is not installed.
|
||||
TimeoutError: if no response arrives in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is not valid JSON / is empty.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"aiomqtt is required for MQTT import. "
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
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(request_payload))
|
||||
|
||||
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 MQTT response: {exc}") from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=response_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 MQTT response") from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty MQTT response received")
|
||||
|
||||
return response_payload
|
||||
|
||||
|
||||
async def test_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/TimeoutError 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,14 +7,16 @@ import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +36,37 @@ _EXTRA_PORTS = (
|
||||
"16686,34567,37777,51413,64738"
|
||||
)
|
||||
|
||||
# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
|
||||
_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeepScanOptions:
|
||||
"""Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
|
||||
|
||||
http_ranges: list[str] = field(default_factory=list)
|
||||
http_probe_enabled: bool = False
|
||||
verify_tls: bool = False
|
||||
|
||||
|
||||
def _valid_port_range(spec: str) -> bool:
|
||||
if not _PORT_RANGE_RE.match(spec):
|
||||
return False
|
||||
parts = [int(p) for p in spec.split("-")]
|
||||
if any(p < 1 or p > 65535 for p in parts):
|
||||
return False
|
||||
return len(parts) == 1 or parts[0] <= parts[1]
|
||||
|
||||
|
||||
def _build_port_spec(http_ranges: list[str] | None) -> str:
|
||||
"""Combine the default port list with validated user ranges for nmap -p."""
|
||||
if not http_ranges:
|
||||
return _EXTRA_PORTS
|
||||
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
||||
if not extra:
|
||||
return _EXTRA_PORTS
|
||||
return _EXTRA_PORTS + "," + ",".join(extra)
|
||||
|
||||
_MDNS_SERVICE_TYPES = [
|
||||
"_http._tcp.local.",
|
||||
"_shelly._tcp.local.",
|
||||
@@ -142,7 +175,7 @@ def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
|
||||
return {}
|
||||
|
||||
|
||||
async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||
async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[str, Any]]:
|
||||
"""
|
||||
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
||||
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
||||
@@ -172,6 +205,12 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
||||
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
||||
|
||||
# Cancelled during the sweep — bail before the (potentially long) Phase 2
|
||||
# port scan. Returning empty makes _nmap_scan skip nmap entirely.
|
||||
if run_id is not None and _is_cancelled(run_id):
|
||||
logger.info("[Phase 1] %s — scan cancelled, skipping hostname/ARP enrichment", target)
|
||||
return {}
|
||||
|
||||
# ARP cache: catch devices that block ICMP but were recently active,
|
||||
# and enrich ping-alive hosts with their MAC addresses.
|
||||
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
||||
@@ -195,7 +234,7 @@ async def _ping_sweep(target: str) -> dict[str, dict[str, Any]]:
|
||||
return alive
|
||||
|
||||
|
||||
def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
||||
"""
|
||||
Phase 2 — single-IP port scan with service detection.
|
||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
||||
@@ -210,11 +249,11 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
is_root = os.geteuid() == 0
|
||||
if is_root:
|
||||
# SYN scan + version detection (fastest, most accurate)
|
||||
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
||||
else:
|
||||
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
||||
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
||||
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {_EXTRA_PORTS}"
|
||||
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
||||
|
||||
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
||||
nm = nmap.PortScanner()
|
||||
@@ -252,7 +291,10 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
return host_dict
|
||||
|
||||
|
||||
async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
async def _nmap_port_scan(
|
||||
alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS,
|
||||
run_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Phase 2: Per-IP service detection with bounded concurrency.
|
||||
Each host is scanned independently in a thread — no inter-host timeout interference.
|
||||
@@ -266,7 +308,12 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
|
||||
|
||||
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
return await asyncio.to_thread(_nmap_scan_single, host_dict)
|
||||
# Once cancelled, skip the expensive nmap call for every host still
|
||||
# queued behind the semaphore — return it unscanned so the gather
|
||||
# unwinds fast instead of blocking the stop for minutes.
|
||||
if run_id is not None and _is_cancelled(run_id):
|
||||
return host_dict
|
||||
return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
|
||||
|
||||
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
||||
results = []
|
||||
@@ -279,24 +326,32 @@ async def _nmap_port_scan(alive: dict[str, dict[str, Any]]) -> list[dict[str, An
|
||||
return results
|
||||
|
||||
|
||||
async def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||
async def _nmap_scan(
|
||||
target: str, port_spec: str = _EXTRA_PORTS, run_id: str | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Two-phase scan for a CIDR range.
|
||||
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
||||
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
||||
|
||||
``run_id`` lets each phase poll for cancellation so a stop request takes
|
||||
effect mid-range instead of only at CIDR/host boundaries in run_scan.
|
||||
"""
|
||||
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
||||
if run_id is not None and _is_cancelled(run_id):
|
||||
logger.info("[Scan] %s — cancelled before start, skipping", target)
|
||||
return []
|
||||
if not _NMAP_AVAILABLE:
|
||||
logger.warning("[Scan] nmap not available — returning mock data")
|
||||
return _mock_scan(target)
|
||||
try:
|
||||
alive = await _ping_sweep(target)
|
||||
alive = await _ping_sweep(target, run_id=run_id)
|
||||
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
||||
len(alive), target, ", ".join(sorted(alive.keys())))
|
||||
except Exception as exc:
|
||||
logger.error("Phase 1 ping sweep failed: %s", exc)
|
||||
raise RuntimeError(str(exc)) from exc
|
||||
return await _nmap_port_scan(alive)
|
||||
return await _nmap_port_scan(alive, port_spec, run_id=run_id)
|
||||
|
||||
|
||||
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
||||
@@ -375,10 +430,50 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
"""Collapse duplicate non-hidden inventory rows that share an IP into one.
|
||||
|
||||
Keeps an ``approved`` row when present (it carries canvas-link semantics),
|
||||
otherwise the oldest row, and deletes the rest. Returns the number deleted.
|
||||
"""
|
||||
rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
by_ip: dict[str, list[PendingDevice]] = {}
|
||||
for row in rows:
|
||||
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
||||
continue
|
||||
by_ip.setdefault(row.ip, []).append(row)
|
||||
|
||||
deleted = 0
|
||||
for group in by_ip.values():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
keep = next((r for r in group if r.status == "approved"), group[0])
|
||||
for dup in group:
|
||||
if dup is not keep:
|
||||
await db.delete(dup)
|
||||
deleted += 1
|
||||
if deleted:
|
||||
await db.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
async def run_scan(
|
||||
ranges: list[str],
|
||||
db: AsyncSession,
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
) -> None:
|
||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
deep_scan = deep_scan or DeepScanOptions()
|
||||
port_spec = _build_port_spec(deep_scan.http_ranges)
|
||||
|
||||
devices_found = 0
|
||||
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
||||
try:
|
||||
@@ -389,25 +484,18 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
||||
|
||||
# Pre-fetch canvas IPs and hidden IPs once — avoids N+1 queries per host
|
||||
canvas_ips_result = await db.execute(select(Node.ip).where(Node.ip.isnot(None)))
|
||||
canvas_ips: set[str] = {row[0] for row in canvas_ips_result.fetchall()}
|
||||
|
||||
# Pre-fetch hidden IPs once — avoids N+1 queries per host.
|
||||
# Devices already on a canvas are intentionally NOT suppressed: they stay
|
||||
# in the inventory and are badged "In N canvas" via per-request correlation.
|
||||
hidden_ips_result = await db.execute(
|
||||
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
||||
)
|
||||
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
||||
|
||||
# Clean up stale pending devices whose IPs are already in the canvas
|
||||
if canvas_ips:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
await db.execute(
|
||||
sa_delete(PendingDevice).where(
|
||||
PendingDevice.status == "pending",
|
||||
PendingDevice.ip.in_(canvas_ips),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
# Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
|
||||
# left over from older scans, so the device shows up exactly once even if
|
||||
# it isn't re-discovered this run (e.g. now offline).
|
||||
await _dedupe_pending_by_ip(db)
|
||||
|
||||
# Start mDNS discovery in the background while nmap scans run
|
||||
mdns_task = asyncio.create_task(_mdns_discover())
|
||||
@@ -419,30 +507,48 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# Skip canvas nodes and user-hidden devices (sets pre-fetched before loop)
|
||||
if ip in canvas_ips:
|
||||
logger.debug("Skipping %s — already in canvas", ip)
|
||||
return
|
||||
# Skip only user-hidden devices. On-canvas devices are kept so they
|
||||
# surface in the inventory with a canvas-presence badge.
|
||||
if ip in hidden_ips:
|
||||
logger.debug("Skipping %s — hidden by user", ip)
|
||||
return
|
||||
|
||||
services = fingerprint_ports(host["open_ports"])
|
||||
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||
|
||||
existing_result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == ip,
|
||||
PendingDevice.status == "pending",
|
||||
open_ports = host["open_ports"]
|
||||
# Deep-scan HTTP probe: enrich open ports with title/header signals so
|
||||
# fingerprint can confirm services on custom ports. No-op when disabled
|
||||
# or when the host has no open ports (e.g. mDNS-only discovery).
|
||||
if deep_scan.http_probe_enabled and open_ports:
|
||||
open_ports = await probe_open_ports(
|
||||
ip, open_ports, verify_tls=deep_scan.verify_tls
|
||||
)
|
||||
)
|
||||
existing = existing_result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.mac = host.get("mac") or existing.mac
|
||||
existing.hostname = host.get("hostname") or existing.hostname
|
||||
existing.os = host.get("os") or existing.os
|
||||
existing.services = services
|
||||
existing.suggested_type = suggested_type
|
||||
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, host.get("mac"))
|
||||
|
||||
# One inventory row per device (by IP). Match across pending AND
|
||||
# approved so a re-scan of an already-approved device refreshes its
|
||||
# row instead of spawning a fresh "pending" duplicate. Hidden rows
|
||||
# are already skipped above.
|
||||
existing_rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
if existing_rows:
|
||||
# Prefer an approved row (it owns the canvas link semantics),
|
||||
# otherwise the oldest. Collapse any leftover duplicates created
|
||||
# by earlier scans.
|
||||
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
||||
for dup in existing_rows:
|
||||
if dup is not keep:
|
||||
await db.delete(dup)
|
||||
keep.mac = host.get("mac") or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
keep.services = services
|
||||
keep.suggested_type = suggested_type
|
||||
# status preserved — an approved device stays approved.
|
||||
else:
|
||||
db.add(PendingDevice(
|
||||
ip=ip,
|
||||
@@ -456,6 +562,20 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
))
|
||||
devices_found += 1
|
||||
|
||||
# Stamp last_scan on any canvas node that matches this device by IP
|
||||
# (or MAC, when known) so the inventory shows when the scanner last
|
||||
# observed it. Matches across designs.
|
||||
host_mac = host.get("mac")
|
||||
node_match = [Node.ip == ip]
|
||||
if host_mac:
|
||||
node_match.append(Node.mac == host_mac)
|
||||
matching_nodes = (await db.execute(
|
||||
select(Node).where(or_(*node_match))
|
||||
)).scalars().all()
|
||||
scanned_at = datetime.now(timezone.utc)
|
||||
for node in matching_nodes:
|
||||
node.last_scan = scanned_at
|
||||
|
||||
await db.commit()
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
@@ -463,7 +583,7 @@ async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
for cidr in ranges:
|
||||
if _is_cancelled(run_id):
|
||||
break
|
||||
hosts = await _nmap_scan(cidr)
|
||||
hosts = await _nmap_scan(cidr, port_spec, run_id=run_id)
|
||||
for host in hosts:
|
||||
if _is_cancelled(run_id):
|
||||
break
|
||||
|
||||
@@ -19,9 +19,16 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
if check_method == "none":
|
||||
return {"status": "online", "response_time_ms": None}
|
||||
|
||||
host = target or ip
|
||||
# Use only the first IP when the field contains comma-separated addresses
|
||||
raw_ip = ip.split(",")[0].strip() if ip else None
|
||||
host = target or raw_ip
|
||||
if not host:
|
||||
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()
|
||||
try:
|
||||
@@ -57,11 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
return {"status": "offline", "response_time_ms": None}
|
||||
|
||||
|
||||
def _is_ipv6(host: str) -> bool:
|
||||
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||
#
|
||||
# -W flag units differ by OS:
|
||||
# Linux: seconds (-W 2 = 2s)
|
||||
# macOS: milliseconds (-W 2000 = 2s)
|
||||
# Windows: -w in ms (-w 2000 = 2s)
|
||||
#
|
||||
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||
ipv6 = _is_ipv6(host)
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
family = ["-6"] if ipv6 else ["-4"]
|
||||
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||
elif sys.platform == "darwin":
|
||||
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
family = ["-6"] if ipv6 else []
|
||||
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
@@ -87,3 +118,75 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
||||
return True
|
||||
except (TimeoutError, OSError, socket.gaierror):
|
||||
return False
|
||||
|
||||
|
||||
# --- Per-service status checks ---
|
||||
|
||||
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
||||
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
||||
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
||||
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
||||
_NON_HTTP_PORTS = frozenset({
|
||||
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||
})
|
||||
_HTTPS_PORTS = frozenset({443, 8443})
|
||||
|
||||
|
||||
def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||
"""Bracket bare IPv6 literals for use in a URL."""
|
||||
return f"[{host}]" if _is_ipv6(host) else host
|
||||
|
||||
|
||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
||||
|
||||
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
||||
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
||||
so it keeps its category colour instead of flashing red. An open TCP socket
|
||||
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
||||
"""
|
||||
if not host or host.startswith("-"):
|
||||
return "unknown"
|
||||
if str(svc.get("protocol", "")).lower() == "udp":
|
||||
return "unknown"
|
||||
|
||||
port = svc.get("port")
|
||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
||||
|
||||
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
||||
if port is not None and port in _NON_HTTP_PORTS:
|
||||
return "unknown"
|
||||
|
||||
name = str(svc.get("service_name", "")).lower()
|
||||
is_web = port is not None or "http" in name
|
||||
if not is_web:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
scheme = "https" if (
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http"
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
return "online" if await _http_get(url, verify=False) else "offline"
|
||||
except Exception as exc:
|
||||
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
||||
return "offline"
|
||||
|
||||
|
||||
async def check_services(
|
||||
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Check every service against host concurrently (bounded).
|
||||
|
||||
Returns a list of {port, protocol, status} dicts, one per input service.
|
||||
"""
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
status = await check_service(svc, host)
|
||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||
|
||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
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)
|
||||
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
||||
|
||||
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
||||
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import request_response, test_connection
|
||||
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
||||
merge_zwave_properties = merge_zigbee_properties
|
||||
|
||||
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
||||
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
||||
|
||||
|
||||
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
||||
"""Map a Z-Wave node's role flags to a homelable node type.
|
||||
|
||||
Controller → coordinator. Mains-powered / routing nodes → router.
|
||||
Everything else (battery sensors, etc.) → end device.
|
||||
"""
|
||||
if raw.get("isControllerNode"):
|
||||
return "zwave_coordinator"
|
||||
if raw.get("isRouting"):
|
||||
return "zwave_router"
|
||||
return "zwave_enddevice"
|
||||
|
||||
|
||||
def _role_label(node_type: str) -> str:
|
||||
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
||||
return {
|
||||
"zwave_coordinator": "Controller",
|
||||
"zwave_router": "Router",
|
||||
"zwave_enddevice": "EndDevice",
|
||||
}.get(node_type, "EndDevice")
|
||||
|
||||
|
||||
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
||||
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
||||
node_id = raw.get("id")
|
||||
if node_id is None:
|
||||
return None
|
||||
ieee = f"zwave-{home_id}-{node_id}"
|
||||
node_type = _zwave_type_to_homelable(raw)
|
||||
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
||||
model = raw.get("productLabel") or raw.get("productDescription") or None
|
||||
vendor = raw.get("manufacturer") or None
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": name,
|
||||
"type": node_type,
|
||||
"ieee_address": ieee,
|
||||
"friendly_name": name,
|
||||
"device_type": _role_label(node_type),
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"vendor": vendor,
|
||||
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
||||
"parent_id": None,
|
||||
"neighbors": raw.get("neighbors") or [],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
||||
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
||||
controller_home = None
|
||||
for entry in raw_nodes:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
home = entry.get("homeId")
|
||||
if home is None:
|
||||
continue
|
||||
if entry.get("isControllerNode"):
|
||||
return str(home)
|
||||
if controller_home is None:
|
||||
controller_home = str(home)
|
||||
return controller_home or "0"
|
||||
|
||||
|
||||
def parse_zwave_nodes(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
||||
|
||||
Expected shape::
|
||||
|
||||
{"success": true, "result": [ {<node>}, ... ]}
|
||||
|
||||
Edges are a strict coordinator → router → end-device tree, derived from
|
||||
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
||||
"""
|
||||
if payload.get("success") is False:
|
||||
raise ValueError("Z-Wave gateway reported failure")
|
||||
|
||||
result = payload.get("result")
|
||||
if result is None:
|
||||
result = []
|
||||
if not isinstance(result, list):
|
||||
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
||||
|
||||
home_id = _resolve_home_id(result)
|
||||
|
||||
nodes_list: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
coordinator_id: str | None = None
|
||||
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
||||
id_by_node_id: dict[Any, str] = {}
|
||||
|
||||
for entry in result:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
node = _node_from_zwave(entry, home_id)
|
||||
if node is None or node["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(node["id"])
|
||||
id_by_node_id[node["node_id"]] = node["id"]
|
||||
nodes_list.append(node)
|
||||
if node["type"] == "zwave_coordinator":
|
||||
coordinator_id = node["id"]
|
||||
|
||||
# Translate neighbor lists into candidate edges (only between known nodes).
|
||||
raw_edges: list[dict[str, Any]] = []
|
||||
for node in nodes_list:
|
||||
src = node["id"]
|
||||
for neighbor in node.get("neighbors") or []:
|
||||
tgt = id_by_node_id.get(neighbor)
|
||||
if tgt and tgt != src:
|
||||
raw_edges.append({"source": src, "target": tgt})
|
||||
|
||||
# Build parent_id hierarchy: coordinator → routers → end devices.
|
||||
if coordinator_id:
|
||||
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
||||
for node in nodes_list:
|
||||
if node["type"] == "zwave_router":
|
||||
node["parent_id"] = coordinator_id
|
||||
elif node["type"] == "zwave_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")
|
||||
]
|
||||
|
||||
# Drop transient helper keys before returning.
|
||||
for node in nodes_list:
|
||||
node.pop("neighbors", None)
|
||||
node.pop("node_id", None)
|
||||
|
||||
return nodes_list, edges_list
|
||||
|
||||
|
||||
def build_zwave_properties(
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
||||
|
||||
Z-Wave has no LQI, so that row is omitted. New props default to
|
||||
``visible=False`` — users opt in from the right panel.
|
||||
"""
|
||||
props: list[dict[str, Any]] = []
|
||||
if ieee:
|
||||
props.append({"key": "Z-Wave ID", "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})
|
||||
return props
|
||||
|
||||
|
||||
async def fetch_zwave_network(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str = "zwave",
|
||||
gateway_name: str = "zwavejs2mqtt",
|
||||
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 broker, request the Z-Wave node list, return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the gateway does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is malformed.
|
||||
"""
|
||||
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
|
||||
payload = await request_response(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
request_topic=request_topic,
|
||||
response_topic=response_topic,
|
||||
request_payload={"args": []},
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
|
||||
return parse_zwave_nodes(payload)
|
||||
|
||||
|
||||
async def test_zwave_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Quick MQTT reachability check for the Z-Wave broker."""
|
||||
return await test_connection(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
@@ -2,3 +2,4 @@
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
scan_config.json
|
||||
homelab.db.*
|
||||
|
||||
@@ -25,6 +25,8 @@ addopts = "--tb=short -q"
|
||||
[tool.coverage.run]
|
||||
source = ["app"]
|
||||
omit = ["*/migrations/*", "*/tests/*"]
|
||||
concurrency = ["thread"]
|
||||
core = "sysmon"
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
||||
@@ -7,20 +7,20 @@ alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.22
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
pyyaml==6.0.2
|
||||
types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.131.0
|
||||
zeroconf==0.149.16
|
||||
aiomqtt==2.3.0
|
||||
|
||||
# Dev
|
||||
ruff==0.6.9
|
||||
mypy==1.11.2
|
||||
pytest==8.3.3
|
||||
pytest-asyncio==0.24.0
|
||||
pytest==9.0.3
|
||||
pytest-asyncio==1.3.0
|
||||
pytest-cov==5.0.0
|
||||
|
||||
@@ -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
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
import bcrypt
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python scripts/hash_password.py <password>")
|
||||
sys.exit(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
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from passlib.context import CryptContext
|
||||
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.main import app
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def test_credentials():
|
||||
"""Configure test auth credentials directly on settings."""
|
||||
from app.core.config import settings
|
||||
settings.auth_username = "admin"
|
||||
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
||||
settings.auth_password_hash = hash_password("admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -68,3 +68,71 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
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
|
||||
|
||||
@@ -51,6 +51,18 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
|
||||
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
||||
|
||||
|
||||
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Router", type="router")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||
# created_at / updated_at always set; last_seen / last_scan null until observed.
|
||||
assert node["created_at"] is not None
|
||||
assert node["updated_at"] is not None
|
||||
assert "last_seen" in node
|
||||
assert node["last_scan"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Old Label")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
@@ -199,6 +211,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
||||
assert canvas["nodes"][0]["show_hardware"] is False
|
||||
|
||||
|
||||
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
||||
# toggle reset on every reload.
|
||||
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(show_port_numbers=True)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is True
|
||||
|
||||
|
||||
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is False
|
||||
|
||||
|
||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
@@ -256,3 +286,343 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["width"] is None
|
||||
assert canvas["nodes"][0]["height"] is None
|
||||
|
||||
|
||||
# ── properties ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_properties_default_empty(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]["properties"] == []
|
||||
|
||||
|
||||
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
||||
props = [
|
||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
||||
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
||||
]
|
||||
n1 = node_payload(properties=props)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
returned = canvas["nodes"][0]["properties"]
|
||||
assert len(returned) == 2
|
||||
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
||||
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
||||
|
||||
|
||||
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_updated = {**n1, "properties": [
|
||||
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
||||
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
||||
]}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
props = canvas["nodes"][0]["properties"]
|
||||
assert len(props) == 2
|
||||
assert props[0]["value"] == "64 GB"
|
||||
assert props[1]["key"] == "Disk"
|
||||
|
||||
|
||||
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
||||
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
||||
n1 = node_payload(properties=props)
|
||||
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]["properties"][0]["icon"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
n1_cleared = {**n1, "properties": []}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["properties"] == []
|
||||
|
||||
|
||||
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["waypoints"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
returned = canvas["edges"][0]["waypoints"]
|
||||
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
||||
|
||||
|
||||
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
||||
|
||||
|
||||
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(bottom_handles=3)
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["source_handle"] == "bottom-1"
|
||||
assert edge["target_handle"] == "top"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["animated"] == "snake"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["edges"][0]["animated"] == "basic"
|
||||
|
||||
|
||||
# ── node fields ───────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(
|
||||
type="server",
|
||||
label="Main Server",
|
||||
hostname="server.local",
|
||||
ip="192.168.1.10",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="Ubuntu 22.04",
|
||||
status="online",
|
||||
check_method="http",
|
||||
check_target="http://192.168.1.10",
|
||||
services=[{"name": "nginx", "port": 80}],
|
||||
notes="Primary web server",
|
||||
pos_x=150.0,
|
||||
pos_y=250.0,
|
||||
bottom_handles=2,
|
||||
)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["hostname"] == "server.local"
|
||||
assert node["ip"] == "192.168.1.10"
|
||||
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert node["os"] == "Ubuntu 22.04"
|
||||
assert node["status"] == "online"
|
||||
assert node["check_method"] == "http"
|
||||
assert node["check_target"] == "http://192.168.1.10"
|
||||
assert node["services"] == [{"name": "nginx", "port": 80}]
|
||||
assert node["notes"] == "Primary web server"
|
||||
assert node["pos_x"] == 150.0
|
||||
assert node["pos_y"] == 250.0
|
||||
assert node["bottom_handles"] == 2
|
||||
|
||||
|
||||
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(bottom_handles=4)
|
||||
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]["bottom_handles"] == 4
|
||||
|
||||
|
||||
async def test_save_canvas_bottom_handles_defaults_one(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]["bottom_handles"] == 1
|
||||
|
||||
|
||||
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
||||
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
||||
n1 = node_payload(services=services, notes="My NAS device")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["services"] == services
|
||||
assert node["notes"] == "My NAS device"
|
||||
|
||||
|
||||
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
||||
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
||||
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
||||
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]["services"] == services
|
||||
|
||||
|
||||
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node = canvas["nodes"][0]
|
||||
assert node["check_method"] == "ping"
|
||||
assert node["check_target"] == "192.168.1.1"
|
||||
|
||||
|
||||
# ── parent/child nodes ────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
||||
parent = node_payload(type="proxmox", label="PVE Host")
|
||||
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
||||
assert node_map[parent["id"]]["parent_id"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
||||
parent = node_payload(type="proxmox", label="PVE Host")
|
||||
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
# Remove both parent and child
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"] == []
|
||||
|
||||
|
||||
# ── groupRect / group node ────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
||||
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
||||
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
||||
assert node_map[group["id"]]["type"] == "group"
|
||||
assert node_map[group["id"]]["width"] == 400.0
|
||||
assert node_map[group["id"]]["height"] == 300.0
|
||||
assert node_map[member["id"]]["parent_id"] == group["id"]
|
||||
|
||||
|
||||
# ── viewport ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||
|
||||
|
||||
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
||||
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
||||
|
||||
|
||||
# ── edge types ────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["type"] == "vlan"
|
||||
assert edge["vlan_id"] == 10
|
||||
assert edge["label"] == "VLAN 10"
|
||||
|
||||
|
||||
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
||||
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
||||
n1 = node_payload()
|
||||
n2 = node_payload()
|
||||
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["label"] == "updated"
|
||||
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,57 @@
|
||||
"""
|
||||
Tests for automatic DB backup before migrations.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.database import _backup_db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tmp_db(tmp_path: Path):
|
||||
db = tmp_path / "homelab.db"
|
||||
db.write_bytes(b"SQLite placeholder")
|
||||
return db
|
||||
|
||||
|
||||
def test_backup_created_when_db_exists(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||
assert backup.exists()
|
||||
assert backup.read_bytes() == b"SQLite placeholder"
|
||||
|
||||
|
||||
def test_backup_skipped_when_db_missing(tmp_path: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
|
||||
_backup_db()
|
||||
assert not any(tmp_path.glob("*.back-*"))
|
||||
|
||||
|
||||
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "1.9"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
||||
backup.write_bytes(b"original backup")
|
||||
_backup_db()
|
||||
assert backup.read_bytes() == b"original backup"
|
||||
|
||||
|
||||
def test_backup_version_in_filename(tmp_db: Path):
|
||||
with patch("app.db.database.settings") as mock_settings, \
|
||||
patch("app.db.database.APP_VERSION", "2.0"):
|
||||
mock_settings.sqlite_path = str(tmp_db)
|
||||
_backup_db()
|
||||
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
|
||||
@@ -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"
|
||||
@@ -97,6 +97,37 @@ async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_create_edge_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict, two_nodes):
|
||||
# Regression for #225: MCP create_edge sent no design_id, so edges were
|
||||
# persisted with design_id=null and never rendered until a restart.
|
||||
src, tgt = two_nodes
|
||||
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
||||
design_id = design.json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "type": "ethernet"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["design_id"] == design_id
|
||||
|
||||
|
||||
async def test_create_edge_respects_explicit_design_id(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
||||
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
||||
second_id = second.json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "type": "ethernet", "design_id": second_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["design_id"] == second_id
|
||||
|
||||
|
||||
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post(
|
||||
|
||||
@@ -2,7 +2,13 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type
|
||||
from app.services.fingerprint import (
|
||||
fingerprint_ports,
|
||||
match_port,
|
||||
match_service,
|
||||
suggest_node_type,
|
||||
suggest_type_from_mac,
|
||||
)
|
||||
|
||||
MOCK_SIGNATURES = [
|
||||
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
||||
@@ -173,3 +179,157 @@ def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
||||
{"port": 1883, "protocol": "tcp"},
|
||||
])
|
||||
assert result == "iot"
|
||||
|
||||
|
||||
# ── OUI vendor detection ──────────────────────────────────────────────────────
|
||||
|
||||
def test_suggest_type_from_mac_mikrotik_returns_router():
|
||||
# The motivating case: MikroTik MAC should be recognized as a router
|
||||
assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
|
||||
assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_ubiquiti_returns_ap():
|
||||
# Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
|
||||
# so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
|
||||
assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
|
||||
assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_synology_returns_nas():
|
||||
assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_qnap_returns_nas():
|
||||
assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_hikvision_returns_camera():
|
||||
assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_dahua_returns_camera():
|
||||
assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_cisco_returns_switch():
|
||||
assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_raspberry_pi_returns_server():
|
||||
assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_handles_uppercase():
|
||||
# MACs may arrive in any case; lookup must be case-insensitive
|
||||
assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
|
||||
|
||||
|
||||
def test_suggest_type_from_mac_unknown_oui_returns_none():
|
||||
assert suggest_type_from_mac("00:00:01:11:22:33") is None
|
||||
|
||||
|
||||
def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
|
||||
# MikroTik device with no scanned ports should still be classified as router via MAC
|
||||
assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
|
||||
|
||||
|
||||
def test_suggest_node_type_synology_mac_with_http_returns_nas():
|
||||
# NAS priority beats server, so a Synology MAC + open HTTP → nas
|
||||
result = suggest_node_type(
|
||||
[{"port": 80, "protocol": "tcp"}],
|
||||
mac="00:11:32:11:22:33",
|
||||
)
|
||||
assert result == "nas"
|
||||
|
||||
|
||||
def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
|
||||
# Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
|
||||
result = suggest_node_type(
|
||||
[{"port": 179, "protocol": "tcp"}],
|
||||
mac="24:a4:3c:11:22:33",
|
||||
)
|
||||
assert result == "router"
|
||||
|
||||
|
||||
# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
|
||||
|
||||
HTTP_SIGNATURES = [
|
||||
# Generic web fallback on 8096 (port-only guess)
|
||||
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
|
||||
"service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
||||
# Same port, but confirmed by HTML title → should win when probe confirms
|
||||
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
||||
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
|
||||
# Port-agnostic: matches on HTTP content regardless of port
|
||||
{"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
|
||||
"service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
|
||||
# Banner-based entry, no http
|
||||
{"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
|
||||
"service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def http_signatures():
|
||||
with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
|
||||
yield
|
||||
|
||||
|
||||
def test_http_regex_confirmed_beats_port_only(http_signatures):
|
||||
# Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
|
||||
sig = match_service(8096, "tcp", banner=None,
|
||||
http_signals={"title": "Jellyfin", "headers": {}})
|
||||
assert sig["service_name"] == "Jellyfin"
|
||||
|
||||
|
||||
def test_http_regex_matches_on_header(http_signatures):
|
||||
sig = match_service(8096, "tcp", banner=None,
|
||||
http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
|
||||
assert sig["service_name"] == "Jellyfin"
|
||||
|
||||
|
||||
def test_http_regex_miss_falls_back_to_port_only(http_signatures):
|
||||
# Probe ran but nothing matched the http_regex → generic port-only entry wins
|
||||
sig = match_service(8096, "tcp", banner=None,
|
||||
http_signals={"title": "Some Other App", "headers": {}})
|
||||
assert sig["service_name"] == "HTTP"
|
||||
|
||||
|
||||
def test_probe_disabled_ignores_http_regex(http_signatures):
|
||||
# http_signals=None (deep scan off) → http_regex entry degrades to port-only,
|
||||
# generic entry (listed first) wins — identical to pre-probe behaviour.
|
||||
sig = match_service(8096, "tcp", banner=None, http_signals=None)
|
||||
assert sig["service_name"] == "HTTP"
|
||||
|
||||
|
||||
def test_port_agnostic_match_on_custom_port(http_signatures):
|
||||
# Portainer found on a non-standard port, recognised purely by HTTP content
|
||||
sig = match_service(54321, "tcp", banner=None,
|
||||
http_signals={"title": "Portainer", "headers": {}})
|
||||
assert sig["service_name"] == "Portainer"
|
||||
|
||||
|
||||
def test_port_agnostic_requires_probe(http_signatures):
|
||||
# Same custom port, probe off → no signal → no match
|
||||
assert match_service(54321, "tcp", banner=None, http_signals=None) is None
|
||||
|
||||
|
||||
def test_banner_match_still_works_with_probe(http_signatures):
|
||||
sig = match_service(9090, "tcp", banner="prometheus 2.x",
|
||||
http_signals={"title": "x", "headers": {}})
|
||||
assert sig["service_name"] == "Prometheus"
|
||||
|
||||
|
||||
def test_match_port_alias_has_no_http(http_signatures):
|
||||
# match_port() is the probe-less alias → http_regex entry degrades to port-only
|
||||
sig = match_port(8096, "tcp")
|
||||
assert sig["service_name"] == "HTTP"
|
||||
|
||||
|
||||
def test_fingerprint_ports_uses_http_signals(http_signatures):
|
||||
results = fingerprint_ports([
|
||||
{"port": 8096, "protocol": "tcp", "banner": None,
|
||||
"http_signals": {"title": "Jellyfin", "headers": {}}},
|
||||
])
|
||||
assert results[0]["service_name"] == "Jellyfin"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Tests for the HTTP probe used by deep-scan service identification."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.http_probe import (
|
||||
_extract_title,
|
||||
probe_open_ports,
|
||||
probe_port,
|
||||
)
|
||||
|
||||
|
||||
def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
|
||||
return httpx.Response(status_code=status, text=text, headers=headers or {})
|
||||
|
||||
|
||||
# ── _extract_title ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_title_basic():
|
||||
assert _extract_title("<html><title>Jellyfin</title></html>") == "Jellyfin"
|
||||
|
||||
|
||||
def test_extract_title_collapses_whitespace():
|
||||
assert _extract_title("<title>\n My App\n</title>") == "My App"
|
||||
|
||||
|
||||
def test_extract_title_missing():
|
||||
assert _extract_title("<html><body>no title</body></html>") is None
|
||||
|
||||
|
||||
def test_extract_title_case_insensitive():
|
||||
assert _extract_title("<TITLE>Portainer</TITLE>") == "Portainer"
|
||||
|
||||
|
||||
# ── probe_port ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_title():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
||||
result = await probe_port("10.0.0.5", 8096)
|
||||
assert result == {"title": "Jellyfin", "headers": {}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_headers():
|
||||
resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
|
||||
result = await probe_port("10.0.0.5", 3000)
|
||||
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_falls_back_to_http():
|
||||
# https raises, http succeeds
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_get(self, url, **kw):
|
||||
calls["n"] += 1
|
||||
if url.startswith("https"):
|
||||
raise httpx.ConnectError("tls fail")
|
||||
return _response("<title>HTTP App</title>")
|
||||
|
||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result["title"] == "HTTP App"
|
||||
assert calls["n"] == 2 # tried https then http
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_no_signal_returns_none():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_timeout_returns_none():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_skips_non_http_ports():
|
||||
# SSH should never trigger an HTTP request
|
||||
get = AsyncMock()
|
||||
with patch("httpx.AsyncClient.get", new=get):
|
||||
result = await probe_port("10.0.0.5", 22)
|
||||
assert result is None
|
||||
get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_verify_tls_flag_passed():
|
||||
with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
|
||||
instance = client_cls.return_value.__aenter__.return_value
|
||||
instance.get = AsyncMock(return_value=_response("<title>X</title>"))
|
||||
await probe_port("10.0.0.5", 8443, verify_tls=True)
|
||||
assert client_cls.call_args.kwargs["verify"] is True
|
||||
|
||||
|
||||
# ── probe_open_ports ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_open_ports_enriches_each_port():
|
||||
async def fake_get(self, url, **kw):
|
||||
if ":8096" in url:
|
||||
return _response("<title>Jellyfin</title>")
|
||||
return _response("")
|
||||
|
||||
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
|
||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||
result = await probe_open_ports("10.0.0.5", ports)
|
||||
|
||||
by_port = {p["port"]: p for p in result}
|
||||
assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
|
||||
assert by_port[9999]["http_signals"] is None
|
||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
payload = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["viewport"].get("theme_id") == "matrix"
|
||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -124,3 +146,84 @@ async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
||||
assert res.status_code == 403
|
||||
assert res.json()["detail"] == "Live view is disabled"
|
||||
|
||||
|
||||
# ── /config (authenticated) — key used to build share links ──────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_requires_auth(client: AsyncClient):
|
||||
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
|
||||
settings.liveview_key = "secret"
|
||||
res = await client.get("/api/v1/liveview/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = "share-me"
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {"enabled": True, "key": "share-me"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = None
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False, "key": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
|
||||
settings.liveview_key = ""
|
||||
headers = await auth_headers()
|
||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"enabled": False, "key": None}
|
||||
|
||||
|
||||
# ── design_id selects which canvas is rendered ───────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
|
||||
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
|
||||
# Create two designs
|
||||
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
||||
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
|
||||
|
||||
# Save a distinct node into each design
|
||||
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
|
||||
payload = {
|
||||
"nodes": [{
|
||||
"id": node_id,
|
||||
"type": "server",
|
||||
"label": label,
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
}],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
"design_id": design["id"],
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
# Requesting d2 returns only the electrical node
|
||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
|
||||
assert res.status_code == 200
|
||||
nodes = res.json()["nodes"]
|
||||
assert [n["id"] for n in nodes] == ["n-elec"]
|
||||
|
||||
# Requesting d1 returns only the network node
|
||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
|
||||
assert res.status_code == 200
|
||||
nodes = res.json()["nodes"]
|
||||
assert [n["id"] for n in nodes] == ["n-net"]
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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_legacy_nodes_gain_last_scan_column(legacy_engine):
|
||||
"""A legacy nodes table (no last_scan) gains the column after init_db."""
|
||||
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:
|
||||
cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
assert "last_scan" in {c[1] for c in cols}
|
||||
# Existing rows backfill to NULL (never scanned yet).
|
||||
last_scan = (await conn.exec_driver_sql(
|
||||
"SELECT last_scan FROM nodes WHERE id='n1'"
|
||||
)).fetchone()
|
||||
assert last_scan[0] is None
|
||||
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()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Unit tests for the shared MQTT helpers in mqtt_common."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.mqtt_common import (
|
||||
_build_tls_context,
|
||||
_sanitize_mqtt_error,
|
||||
request_response,
|
||||
)
|
||||
from app.services.mqtt_common import test_connection as _test_connection
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_mqtt_error — never leak credentials
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sanitize_auth_error() -> None:
|
||||
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
|
||||
assert msg == "Authentication failed"
|
||||
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() -> None:
|
||||
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
|
||||
assert msg == "Broker hostname could not be resolved"
|
||||
assert "broker.lan" not in msg
|
||||
|
||||
|
||||
def test_sanitize_tls() -> None:
|
||||
assert _sanitize_mqtt_error(
|
||||
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
||||
) == "TLS handshake failed"
|
||||
|
||||
|
||||
def test_sanitize_timeout() -> None:
|
||||
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
|
||||
|
||||
|
||||
def test_sanitize_unknown_falls_back() -> None:
|
||||
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
|
||||
assert msg == "MQTT connection failed"
|
||||
assert "hunter2" not in msg
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _build_tls_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tls_secure_verifies() -> None:
|
||||
ctx = _build_tls_context(insecure=False)
|
||||
assert ctx.check_hostname is True
|
||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
||||
|
||||
|
||||
def test_tls_insecure_disables_verification() -> None:
|
||||
ctx = _build_tls_context(insecure=True)
|
||||
assert ctx.check_hostname is False
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# request_response (mocked aiomqtt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE = {"success": True, "result": []}
|
||||
|
||||
|
||||
def _fake_client_factory(topic: str, payload: dict):
|
||||
class _FakeMessage:
|
||||
_yielded = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.topic = topic
|
||||
self.payload = json.dumps(payload).encode()
|
||||
|
||||
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()
|
||||
|
||||
return _FakeClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_success() -> None:
|
||||
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
out = await request_response(
|
||||
"localhost", 1883, "req/topic", topic, {"args": []}
|
||||
)
|
||||
assert out == _SAMPLE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_connection_error() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("Connection refused")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
with pytest.raises(ConnectionError):
|
||||
await request_response("bad", 1883, "req", "resp", {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_passes_tls_context() -> None:
|
||||
topic = "resp"
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
|
||||
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
||||
assert ctx.verify_mode == ssl.CERT_NONE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_success() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
assert await _test_connection("localhost", 1883) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_failure() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("refused")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
with pytest.raises(ConnectionError):
|
||||
await _test_connection("bad", 1883)
|
||||
@@ -67,6 +67,50 @@ async def test_update_node_not_found(client: AsyncClient, headers: dict):
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_create_node_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict):
|
||||
# Regression for #225: MCP create_node sent no design_id, so nodes were
|
||||
# persisted with design_id=null and never rendered on the canvas until a
|
||||
# container restart reconciled them. They must attach to a design on create.
|
||||
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
||||
design_id = design.json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "mcp-node", "ip": "192.168.18.99"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["design_id"] == design_id
|
||||
|
||||
|
||||
async def test_create_node_respects_explicit_design_id(client: AsyncClient, headers: dict):
|
||||
# When a design_id is supplied it must win over the first-design fallback.
|
||||
first = await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
||||
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
||||
second_id = second.json()["id"]
|
||||
assert first.json()["id"] != second_id
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "n", "design_id": second_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["design_id"] == second_id
|
||||
|
||||
|
||||
async def test_create_node_without_any_design_stays_null(client: AsyncClient, headers: dict):
|
||||
# No designs exist yet: fallback can't invent one, so design_id stays null
|
||||
# rather than erroring.
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "orphan"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["design_id"] is None
|
||||
|
||||
|
||||
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
|
||||
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
|
||||
assert res.status_code == 404
|
||||
@@ -115,3 +159,97 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
||||
async def test_create_node_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Properties tests ---
|
||||
|
||||
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
||||
"""New node has an empty properties list by default."""
|
||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
||||
"""Node created with properties round-trips correctly."""
|
||||
props = [
|
||||
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
||||
]
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties replaces the full properties array."""
|
||||
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
||||
"""PATCH that omits properties leaves existing properties untouched."""
|
||||
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
# PATCH only the label — properties must survive
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
assert res.json()["label"] == "Updated"
|
||||
|
||||
|
||||
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
||||
"""PATCH with properties=[] explicitly clears all properties."""
|
||||
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == []
|
||||
|
||||
|
||||
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
||||
"""GET /nodes/:id returns the properties field."""
|
||||
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["properties"] == props
|
||||
|
||||
|
||||
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||
"""A property with icon=null is valid and round-trips correctly."""
|
||||
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["properties"] == props
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
Tests for the hardware → properties migration logic.
|
||||
|
||||
We test the migration function directly against an in-memory SQLite database
|
||||
so we can set up legacy rows (with hardware columns, NULL properties) and
|
||||
verify the migration produces the expected properties JSON.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
async def _setup_legacy_table(conn):
|
||||
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
||||
await conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL DEFAULT 'generic',
|
||||
label TEXT NOT NULL DEFAULT '',
|
||||
cpu_model TEXT,
|
||||
cpu_count INTEGER,
|
||||
ram_gb REAL,
|
||||
disk_gb REAL,
|
||||
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
||||
properties JSON
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def _run_migration(conn):
|
||||
"""Run only the properties migration portion (extracted from init_db)."""
|
||||
rows = await conn.exec_driver_sql(
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
||||
if cpu_count is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
||||
if ram_gb is not None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(json.dumps(props), node_id),
|
||||
)
|
||||
|
||||
|
||||
async def _get_properties(conn, node_id: str) -> list:
|
||||
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
||||
raw = rows.fetchone()[0]
|
||||
return json.loads(raw) if raw else []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_full_hardware():
|
||||
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-1")
|
||||
|
||||
assert len(props) == 4
|
||||
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
||||
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
||||
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
||||
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_partial_hardware():
|
||||
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-2", "Ryzen 5 5600", 16.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-2")
|
||||
|
||||
assert len(props) == 2
|
||||
assert props[0]["key"] == "CPU Model"
|
||||
assert props[0]["visible"] is False
|
||||
assert props[1]["key"] == "RAM"
|
||||
assert props[1]["icon"] == "MemoryStick"
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_no_hardware():
|
||||
"""Node with no hardware fields → empty properties array."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id) VALUES (?)",
|
||||
("node-3",),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-3")
|
||||
|
||||
assert props == []
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_idempotent():
|
||||
"""Running migration twice does not duplicate properties."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
||||
("node-4", "Core i5", 1),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
||||
props = await _get_properties(conn, "node-4")
|
||||
|
||||
assert len(props) == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_show_hardware_false_sets_visible_false():
|
||||
"""show_hardware=0 means all migrated properties have visible=False."""
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
||||
("node-5", "ARM Cortex-A72", 4.0, 0),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-5")
|
||||
|
||||
assert all(p["visible"] is False for p in props)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_already_migrated_node_not_touched():
|
||||
"""Node that already has properties is skipped — existing properties preserved."""
|
||||
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await _setup_legacy_table(conn)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
||||
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
||||
)
|
||||
await _run_migration(conn)
|
||||
props = await _get_properties(conn, "node-6")
|
||||
|
||||
assert props == existing
|
||||
await engine.dispose()
|
||||
@@ -277,6 +277,74 @@ async def test_nmap_scan_raises_on_sweep_error():
|
||||
await _nmap_scan("192.168.1.0/24")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cancellation responsiveness (issue #218)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nmap_scan_cancelled_before_start_skips_phases():
|
||||
"""A run already cancelled returns immediately without touching the network."""
|
||||
from app.services.scanner import _cancelled_runs, _nmap_scan, request_cancel
|
||||
|
||||
run_id = "cancel-before-start"
|
||||
request_cancel(run_id)
|
||||
try:
|
||||
with patch("app.services.scanner._ping_sweep", new_callable=AsyncMock) as mock_sweep, \
|
||||
patch("app.services.scanner._nmap_port_scan", new_callable=AsyncMock) as mock_port:
|
||||
result = await _nmap_scan("192.168.1.0/24", run_id=run_id)
|
||||
assert result == []
|
||||
mock_sweep.assert_not_called()
|
||||
mock_port.assert_not_called()
|
||||
finally:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_sweep_cancelled_mid_sweep_returns_empty():
|
||||
"""Cancelling during Phase 1 bails before Phase 2 — no alive hosts returned."""
|
||||
from app.services.scanner import _cancelled_runs, _ping_sweep, request_cancel
|
||||
|
||||
run_id = "cancel-during-sweep"
|
||||
|
||||
async def _fake_subprocess(*args, **kwargs):
|
||||
proc = AsyncMock()
|
||||
proc.wait = AsyncMock(return_value=1)
|
||||
proc.returncode = 1
|
||||
return proc
|
||||
|
||||
request_cancel(run_id)
|
||||
try:
|
||||
with patch("app.services.scanner.asyncio.create_subprocess_exec", new=_fake_subprocess), \
|
||||
patch("app.services.scanner._arp_table_hosts", return_value={}):
|
||||
result = await _ping_sweep("192.168.1.0/30", run_id=run_id)
|
||||
assert result == {}
|
||||
finally:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nmap_port_scan_skips_queued_hosts_when_cancelled():
|
||||
"""Once cancelled, queued hosts return unscanned instead of invoking nmap."""
|
||||
from app.services.scanner import _cancelled_runs, _nmap_port_scan, request_cancel
|
||||
|
||||
run_id = "cancel-port-scan"
|
||||
alive = {
|
||||
"192.168.1.10": {
|
||||
"ip": "192.168.1.10", "mac": None, "hostname": None,
|
||||
"os": None, "open_ports": [],
|
||||
},
|
||||
}
|
||||
request_cancel(run_id)
|
||||
try:
|
||||
with patch("app.services.scanner._nmap_scan_single") as mock_single:
|
||||
result = await _nmap_port_scan(alive, run_id=run_id)
|
||||
mock_single.assert_not_called()
|
||||
assert result[0]["ip"] == "192.168.1.10"
|
||||
assert result[0]["open_ports"] == []
|
||||
finally:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _mdns_discover
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -359,7 +427,7 @@ async def test_nmap_port_scan_tolerates_single_host_exception():
|
||||
|
||||
call_count = 0
|
||||
|
||||
def _flaky_scan(host_dict):
|
||||
def _flaky_scan(host_dict, port_spec=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if host_dict["ip"] == "192.168.1.1":
|
||||
@@ -404,6 +472,84 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
assert any(d.ip == "192.168.1.5" for d in devices)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||
"""A node with no IP but a matching MAC still gets last_scan stamped."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n2")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n3")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
||||
@@ -457,8 +603,9 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_canvas_nodes(mem_db):
|
||||
"""Hosts already approved onto the canvas must be skipped."""
|
||||
async def test_run_scan_keeps_canvas_nodes(mem_db):
|
||||
"""Hosts already on a canvas are NOT suppressed — they stay in the inventory
|
||||
(badged "In N canvas" via correlation), so a re-scan still records them."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
@@ -481,7 +628,9 @@ async def test_run_scan_skips_canvas_nodes(mem_db):
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
||||
assert result.scalar_one_or_none() is None
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.status == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -533,3 +682,135 @@ async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
|
||||
run = await session.get(ScanRun, run_id)
|
||||
assert run is not None
|
||||
assert run.status == "cancelled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deep scan: port-range plumbing + HTTP probe
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_valid_port_range():
|
||||
from app.services.scanner import _valid_port_range
|
||||
|
||||
assert _valid_port_range("8080")
|
||||
assert _valid_port_range("8000-8100")
|
||||
assert not _valid_port_range("8100-8000") # reversed
|
||||
assert not _valid_port_range("0") # below 1
|
||||
assert not _valid_port_range("70000") # above 65535
|
||||
assert not _valid_port_range("abc")
|
||||
assert not _valid_port_range("80,443") # not a single range
|
||||
|
||||
|
||||
def test_build_port_spec_default_when_empty():
|
||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||
|
||||
assert _build_port_spec([]) == _EXTRA_PORTS
|
||||
assert _build_port_spec(None) == _EXTRA_PORTS
|
||||
|
||||
|
||||
def test_build_port_spec_appends_valid_ranges():
|
||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||
|
||||
spec = _build_port_spec(["8000-8100", "9000"])
|
||||
assert spec == _EXTRA_PORTS + ",8000-8100,9000"
|
||||
|
||||
|
||||
def test_build_port_spec_drops_invalid_ranges():
|
||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
||||
|
||||
# invalid entries silently dropped; only valid kept
|
||||
assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
|
||||
assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
|
||||
from app.services.scanner import DeepScanOptions, run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_nmap(target, port_spec, run_id=None):
|
||||
captured["port_spec"] = port_spec
|
||||
return []
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(
|
||||
["192.168.1.0/24"], session, run_id,
|
||||
deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
|
||||
)
|
||||
|
||||
assert "8000-8100" in captured["port_spec"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_probe_enriches_services(mem_db):
|
||||
"""With probe enabled, a custom-port service is identified via HTTP signals."""
|
||||
from app.services.scanner import DeepScanOptions, run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{
|
||||
"ip": "192.168.1.50", "hostname": None, "mac": None, "os": None,
|
||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
||||
}]
|
||||
jellyfin_sig = [{
|
||||
"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
||||
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
|
||||
}]
|
||||
|
||||
async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
|
||||
return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.services.scanner.probe_open_ports", new=fake_probe), \
|
||||
patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(
|
||||
["192.168.1.0/24"], session, run_id,
|
||||
deep_scan=DeepScanOptions(http_probe_enabled=True),
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
|
||||
device = result.scalar_one_or_none()
|
||||
|
||||
assert device is not None
|
||||
assert any(s["service_name"] == "Jellyfin" for s in device.services)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_no_probe_when_disabled(mem_db):
|
||||
"""Probe must not be called on a standard (non-deep) scan."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{
|
||||
"ip": "192.168.1.51", "hostname": None, "mac": None, "os": None,
|
||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
||||
}]
|
||||
probe = AsyncMock()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
||||
patch("app.services.scanner.probe_open_ports", new=probe), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
probe.assert_not_called()
|
||||
|
||||
@@ -5,7 +5,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
||||
from app.core.scheduler import (
|
||||
_run_service_checks,
|
||||
_run_status_checks,
|
||||
set_service_checks_enabled,
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
)
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node
|
||||
|
||||
@@ -141,6 +147,7 @@ def test_scheduler_uses_settings_interval():
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
mock_settings.status_checker_interval = 45
|
||||
mock_settings.service_check_enabled = False
|
||||
start_scheduler()
|
||||
_, kwargs = mock_sched.add_job.call_args
|
||||
assert kwargs["seconds"] == 45
|
||||
@@ -155,3 +162,90 @@ def test_start_and_stop_scheduler():
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.start.assert_called_once()
|
||||
mock_sched.shutdown.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service checks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
||||
mock_settings.service_check_enabled = False
|
||||
await _run_service_checks()
|
||||
mock_cs.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
async with mem_db() as session:
|
||||
node = _make_node(
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
|
||||
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
||||
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
||||
mock_settings.service_check_enabled = True
|
||||
await _run_service_checks()
|
||||
|
||||
mock_bcast.assert_awaited_once()
|
||||
_, kwargs = mock_bcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
assert kwargs["services"] == statuses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
||||
mock_settings.service_check_enabled = True
|
||||
await _run_service_checks()
|
||||
mock_cs.assert_not_called()
|
||||
|
||||
|
||||
def test_set_service_checks_enabled_adds_and_removes_job():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||
patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.service_check_interval = 300
|
||||
# Enable: no existing job -> add
|
||||
mock_sched.get_job.return_value = None
|
||||
set_service_checks_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
# Disable: existing job -> remove
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_service_checks_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("service_checks")
|
||||
|
||||
|
||||
def test_start_scheduler_adds_service_job_when_enabled():
|
||||
mock_sched = MagicMock()
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
mock_settings.status_checker_interval = 60
|
||||
mock_settings.service_check_enabled = True
|
||||
mock_settings.service_check_interval = 300
|
||||
start_scheduler()
|
||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||
assert "status_checks" in job_ids
|
||||
assert "service_checks" in job_ids
|
||||
|
||||
@@ -45,3 +45,42 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/settings", headers=headers)
|
||||
data = res.json()
|
||||
assert "service_check_enabled" in data
|
||||
assert "service_check_interval" in data
|
||||
assert isinstance(data["service_check_enabled"], bool)
|
||||
assert isinstance(data["service_check_interval"], int)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.settings.settings") as mock_settings:
|
||||
mock_settings.save_overrides = lambda: None
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={
|
||||
"interval_seconds": 60,
|
||||
"service_check_enabled": True,
|
||||
"service_check_interval": 600,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["service_check_enabled"] is True
|
||||
assert body["service_check_interval"] == 600
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Integrity + matching tests against the real service_signatures.json."""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.fingerprint import _load, match_service
|
||||
|
||||
_NODE_TYPES = {
|
||||
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
|
||||
"nas", "iot", "ap", "camera", "generic",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def signatures():
|
||||
return _load()
|
||||
|
||||
|
||||
def test_all_entries_well_formed(signatures):
|
||||
for sig in signatures:
|
||||
# port is an int or explicitly null (port-agnostic)
|
||||
assert sig.get("port") is None or isinstance(sig["port"], int)
|
||||
assert isinstance(sig["service_name"], str) and sig["service_name"]
|
||||
assert sig["suggested_node_type"] in _NODE_TYPES
|
||||
if sig.get("banner_regex"):
|
||||
re.compile(sig["banner_regex"])
|
||||
if sig.get("http_regex"):
|
||||
re.compile(sig["http_regex"])
|
||||
|
||||
|
||||
def test_port_agnostic_entries_require_http_regex(signatures):
|
||||
for sig in signatures:
|
||||
if sig.get("port") is None:
|
||||
assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
|
||||
|
||||
|
||||
def test_popular_apps_have_port_agnostic_signatures(signatures):
|
||||
names = {s["service_name"] for s in signatures if s.get("port") is None}
|
||||
for expected in {
|
||||
"Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
|
||||
"AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
|
||||
}:
|
||||
assert expected in names, f"missing port-agnostic signature for {expected}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("title", "expected"), [
|
||||
("Jellyfin", "Jellyfin"),
|
||||
("Home Assistant", "Home Assistant"),
|
||||
("Portainer", "Portainer"),
|
||||
("Vaultwarden Web Vault", "Vaultwarden"),
|
||||
("Pi-hole - Dashboard", "Pi-hole"),
|
||||
("Audiobookshelf", "Audiobookshelf"),
|
||||
])
|
||||
def test_custom_port_identified_via_http_title(title, expected):
|
||||
# A service on a non-standard port, recognised purely by its HTML title.
|
||||
sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
|
||||
assert sig is not None
|
||||
assert sig["service_name"] == expected
|
||||
|
||||
|
||||
def test_custom_port_without_probe_is_unknown():
|
||||
# Same custom port, deep scan off → no signal → no port-agnostic match.
|
||||
assert match_service(58000, "tcp", banner=None, http_signals=None) is None
|
||||
@@ -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 starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
||||
from app.api.routes.status import (
|
||||
_connections,
|
||||
_drop,
|
||||
broadcast_scan_update,
|
||||
broadcast_service_status,
|
||||
broadcast_status,
|
||||
)
|
||||
from app.main import app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -155,3 +161,63 @@ async def test_broadcast_no_connections():
|
||||
assert len(_connections) == 0
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# broadcast_service_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_service_status_payload():
|
||||
received: list[str] = []
|
||||
|
||||
class FakeWS:
|
||||
async def send_text(self, text: str) -> None:
|
||||
received.append(text)
|
||||
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_service_status(
|
||||
node_id="node-7",
|
||||
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
||||
checked_at="2024-01-01T00:00:00",
|
||||
)
|
||||
finally:
|
||||
_drop(fake)
|
||||
|
||||
msg = json.loads(received[0])
|
||||
assert msg["type"] == "service_status"
|
||||
assert msg["node_id"] == "node-7"
|
||||
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _drop — idempotent connection removal (regression for double-remove crash)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_drop_is_idempotent():
|
||||
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
||||
class FakeWS:
|
||||
pass
|
||||
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
_drop(fake)
|
||||
_drop(fake) # second drop must be a no-op
|
||||
assert fake not in _connections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_dead_connection_dropped_once_safely():
|
||||
"""A send failure removes the dead socket without a double-remove crash."""
|
||||
class DeadWS:
|
||||
async def send_text(self, _: str) -> None:
|
||||
raise RuntimeError("disconnected")
|
||||
|
||||
dead = DeadWS()
|
||||
_connections.append(dead)
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
# A second broadcast must not raise even though dead is already gone.
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
assert dead not in _connections
|
||||
|
||||
@@ -3,7 +3,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.status_checker import _ping, _tcp_connect, check_node
|
||||
from app.services.status_checker import (
|
||||
_ping,
|
||||
_tcp_connect,
|
||||
check_node,
|
||||
check_service,
|
||||
check_services,
|
||||
)
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@@ -169,6 +175,36 @@ async def test_ping_uses_unix_args_on_non_windows():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
assert "-n" not in captured["args"]
|
||||
# 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
|
||||
@@ -191,6 +227,100 @@ async def test_ping_uses_windows_args_on_win32():
|
||||
assert "-c" not in captured["args"]
|
||||
|
||||
|
||||
# --- _ping IPv6 support ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_linux_uses_dash6():
|
||||
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("fe80::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
assert captured["args"][-1] == "fe80::1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_macos_uses_ping6():
|
||||
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert captured["args"][0] == "ping6"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_windows_uses_dash6():
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
|
||||
|
||||
def test_is_ipv6_detection():
|
||||
from app.services.status_checker import _is_ipv6
|
||||
|
||||
assert _is_ipv6("fe80::1") is True
|
||||
assert _is_ipv6("2001:db8::1") is True
|
||||
assert _is_ipv6("[2001:db8::1]") is True
|
||||
assert _is_ipv6("192.168.1.1") is False
|
||||
assert _is_ipv6("example.local") is False
|
||||
|
||||
|
||||
# --- check_node target validation ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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 ---
|
||||
|
||||
@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")):
|
||||
result = await _tcp_connect("192.168.1.1", 9999)
|
||||
assert result is False
|
||||
|
||||
|
||||
# --- check_service ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_no_host_is_unknown():
|
||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_flag_host_is_unknown():
|
||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_udp_is_unknown():
|
||||
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_portless_non_web_is_unknown():
|
||||
svc = {"protocol": "tcp", "service_name": "thing"}
|
||||
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_web_uses_http_get():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "online"
|
||||
assert captured["url"] == "http://10.0.0.1:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_https_port_uses_https_scheme():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, "10.0.0.1")
|
||||
assert captured["url"].startswith("https://")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_web_offline_when_http_fails():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
||||
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_non_http_port_is_unknown():
|
||||
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_tcp.assert_not_called()
|
||||
mock_http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_ssh_port_22_is_unknown():
|
||||
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_tcp.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_ipv6_brackets_url_host():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, "2001:db8::1")
|
||||
assert captured["url"] == "http://[2001:db8::1]:80"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_returns_status_per_service():
|
||||
services = [
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
||||
]
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||
results = await check_services("10.0.0.1", services)
|
||||
assert results == [
|
||||
{"port": 80, "protocol": "tcp", "status": "online"},
|
||||
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_empty_list():
|
||||
assert await check_services("10.0.0.1", []) == []
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,447 @@
|
||||
"""API endpoint tests for /api/v1/zwave/*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
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"})
|
||||
token = res.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zwave/test-connection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
||||
mock_conn.return_value = True
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/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.zwave.test_zwave_connection") as mock_conn:
|
||||
mock_conn.side_effect = ConnectionError("Connection refused")
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/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/zwave/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/zwave/test-connection",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zwave/import
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SAMPLE_NODES = [
|
||||
{
|
||||
"id": "zwave-0xh-1",
|
||||
"label": "Controller",
|
||||
"type": "zwave_coordinator",
|
||||
"ieee_address": "zwave-0xh-1",
|
||||
"friendly_name": "Controller",
|
||||
"device_type": "Controller",
|
||||
"model": None,
|
||||
"vendor": None,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
},
|
||||
{
|
||||
"id": "zwave-0xh-2",
|
||||
"label": "Wall Plug",
|
||||
"type": "zwave_router",
|
||||
"ieee_address": "zwave-0xh-2",
|
||||
"friendly_name": "Wall Plug",
|
||||
"device_type": "Router",
|
||||
"model": "ZW100",
|
||||
"vendor": "Aeotec",
|
||||
"lqi": None,
|
||||
"parent_id": "zwave-0xh-1",
|
||||
},
|
||||
]
|
||||
|
||||
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["device_count"] == 2
|
||||
assert len(data["edges"]) == 1
|
||||
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
|
||||
assert coordinator["ieee_address"] == "zwave-0xh-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.return_value = ([], [])
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"prefix": "myzwave",
|
||||
"gateway_name": "gw1",
|
||||
"mqtt_username": "admin",
|
||||
"mqtt_password": "secret",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
mock_fetch.assert_called_once_with(
|
||||
mqtt_host="localhost",
|
||||
mqtt_port=1883,
|
||||
prefix="myzwave",
|
||||
gateway_name="gw1",
|
||||
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.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/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.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.side_effect = TimeoutError("timed out")
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/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.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.side_effect = ValueError("malformed response")
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.side_effect = RuntimeError("boom")
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/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/zwave/import-pending
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PENDING_NODES = [
|
||||
{
|
||||
"id": "zwave-0xh-1",
|
||||
"label": "Controller",
|
||||
"type": "zwave_coordinator",
|
||||
"ieee_address": "zwave-0xh-1",
|
||||
"friendly_name": "Controller",
|
||||
"device_type": "Controller",
|
||||
"model": None,
|
||||
"vendor": None,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
},
|
||||
{
|
||||
"id": "zwave-0xh-2",
|
||||
"label": "Wall Plug",
|
||||
"type": "zwave_router",
|
||||
"ieee_address": "zwave-0xh-2",
|
||||
"friendly_name": "Wall Plug",
|
||||
"device_type": "Router",
|
||||
"model": "ZW100",
|
||||
"vendor": "Aeotec",
|
||||
"lqi": None,
|
||||
"parent_id": "zwave-0xh-1",
|
||||
},
|
||||
{
|
||||
"id": "zwave-0xh-3",
|
||||
"label": "Door Sensor",
|
||||
"type": "zwave_enddevice",
|
||||
"ieee_address": "zwave-0xh-3",
|
||||
"friendly_name": "Door Sensor",
|
||||
"device_type": "EndDevice",
|
||||
"model": "ZW120",
|
||||
"vendor": "Aeotec",
|
||||
"lqi": None,
|
||||
"parent_id": "zwave-0xh-2",
|
||||
},
|
||||
]
|
||||
|
||||
_PENDING_EDGES = [
|
||||
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
|
||||
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
run = res.json()
|
||||
assert run["kind"] == "zwave"
|
||||
assert run["status"] == "running"
|
||||
assert run["ranges"] == ["localhost:1883"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import-pending",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
)
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
|
||||
from app.api.routes.zwave 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 == "zwave-0xh-1"
|
||||
assert result.coordinator_already_existed is False
|
||||
assert result.links_recorded == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["model"] = "ZW111"
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_replaces_links(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDeviceLink
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
||||
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) == ("zwave-0xh-1", "zwave-0xh-2")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_sets_coordinator_properties(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
|
||||
nodes = [dict(n) for n in _PENDING_NODES]
|
||||
nodes[0]["vendor"] = "Aeotec"
|
||||
nodes[0]["model"] = "ZW090"
|
||||
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
|
||||
assert all(p["visible"] is False for p in coord.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Node, PendingDevice
|
||||
|
||||
approved = Node(
|
||||
label="Wall Plug",
|
||||
type="zwave_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
ieee_address="zwave-0xh-2",
|
||||
services=[],
|
||||
properties=[],
|
||||
)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
pendings = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalars().all()
|
||||
assert pendings == []
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
||||
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
|
||||
orphan = PendingDevice(
|
||||
ieee_address="zwave-0xh-2",
|
||||
friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router",
|
||||
device_subtype="Router",
|
||||
status="approved",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
db_session.add(orphan)
|
||||
await db_session.commit()
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
revived = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
assert result.pending_created == 1
|
||||
assert result.pending_updated == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import PendingDevice
|
||||
|
||||
hidden = PendingDevice(
|
||||
ieee_address="zwave-0xh-2",
|
||||
friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router",
|
||||
device_subtype="Router",
|
||||
status="hidden",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
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 == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Unit tests for zwave_service: parser, role mapping, hierarchy builder."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.zwave_service import (
|
||||
build_zwave_properties,
|
||||
fetch_zwave_network,
|
||||
parse_zwave_nodes,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — real zwavejs2mqtt getNodes shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _node(
|
||||
node_id: int,
|
||||
*,
|
||||
controller: bool = False,
|
||||
routing: bool = False,
|
||||
name: str | None = None,
|
||||
neighbors: list[int] | None = None,
|
||||
manufacturer: str | None = None,
|
||||
product_label: str | None = None,
|
||||
home_id: str = "0xabcd1234",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node_id,
|
||||
"homeId": home_id,
|
||||
"isControllerNode": controller,
|
||||
"isRouting": routing,
|
||||
"name": name,
|
||||
"neighbors": neighbors or [],
|
||||
"manufacturer": manufacturer,
|
||||
"productLabel": product_label,
|
||||
}
|
||||
|
||||
|
||||
def _wrap(nodes: list[dict[str, Any]], success: bool = True) -> dict[str, Any]:
|
||||
return {"success": success, "result": nodes}
|
||||
|
||||
|
||||
HOME = "0xabcd1234"
|
||||
|
||||
|
||||
def _ieee(node_id: int) -> str:
|
||||
return f"zwave-{HOME}-{node_id}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role mapping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRoleMapping:
|
||||
def test_controller_is_coordinator(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True)]))
|
||||
assert nodes[0]["type"] == "zwave_coordinator"
|
||||
assert nodes[0]["device_type"] == "Controller"
|
||||
|
||||
def test_routing_is_router(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(2, routing=True)]))
|
||||
assert nodes[0]["type"] == "zwave_router"
|
||||
assert nodes[0]["device_type"] == "Router"
|
||||
|
||||
def test_default_is_enddevice(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(3)]))
|
||||
assert nodes[0]["type"] == "zwave_enddevice"
|
||||
assert nodes[0]["device_type"] == "EndDevice"
|
||||
|
||||
def test_controller_wins_over_routing(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(1, controller=True, routing=True)]))
|
||||
assert nodes[0]["type"] == "zwave_coordinator"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_zwave_nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestParse:
|
||||
def test_empty_payload(self) -> None:
|
||||
nodes, edges = parse_zwave_nodes({})
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_empty_result(self) -> None:
|
||||
nodes, edges = parse_zwave_nodes(_wrap([]))
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_success_false_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="failure"):
|
||||
parse_zwave_nodes(_wrap([], success=False))
|
||||
|
||||
def test_result_not_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="not a list"):
|
||||
parse_zwave_nodes({"success": True, "result": "oops"})
|
||||
|
||||
def test_missing_id_skipped(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([{"homeId": HOME, "isControllerNode": False}]))
|
||||
assert nodes == []
|
||||
|
||||
def test_ieee_identity_format(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(5, controller=True)]))
|
||||
assert nodes[0]["ieee_address"] == _ieee(5)
|
||||
|
||||
def test_name_fallback(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(7, name="Living Room")]))
|
||||
assert nodes[0]["label"] == "Living Room"
|
||||
assert nodes[0]["friendly_name"] == "Living Room"
|
||||
|
||||
def test_model_and_vendor(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(
|
||||
_wrap([_node(8, manufacturer="Aeotec", product_label="ZW100")])
|
||||
)
|
||||
assert nodes[0]["vendor"] == "Aeotec"
|
||||
assert nodes[0]["model"] == "ZW100"
|
||||
|
||||
def test_lqi_is_none(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(9)]))
|
||||
assert nodes[0]["lqi"] is None
|
||||
|
||||
def test_no_duplicate_nodes(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(1, routing=True), _node(1, routing=True)]))
|
||||
assert len(nodes) == 1
|
||||
|
||||
def test_helper_keys_stripped(self) -> None:
|
||||
nodes, _ = parse_zwave_nodes(_wrap([_node(1, neighbors=[2])]))
|
||||
assert "neighbors" not in nodes[0]
|
||||
assert "node_id" not in nodes[0]
|
||||
|
||||
|
||||
class TestHierarchy:
|
||||
def test_coordinator_router_enddevice_tree(self) -> None:
|
||||
payload = _wrap([
|
||||
_node(1, controller=True, neighbors=[2]),
|
||||
_node(2, routing=True, neighbors=[1, 3]),
|
||||
_node(3, neighbors=[2]),
|
||||
])
|
||||
nodes, edges = parse_zwave_nodes(payload)
|
||||
by_id = {n["id"]: n for n in nodes}
|
||||
assert by_id[_ieee(2)]["parent_id"] == _ieee(1)
|
||||
assert by_id[_ieee(3)]["parent_id"] == _ieee(2)
|
||||
pairs = {(e["source"], e["target"]) for e in edges}
|
||||
assert pairs == {(_ieee(1), _ieee(2)), (_ieee(2), _ieee(3))}
|
||||
|
||||
def test_enddevice_without_router_falls_back_to_coordinator(self) -> None:
|
||||
payload = _wrap([_node(1, controller=True), _node(3, neighbors=[])])
|
||||
nodes, _ = parse_zwave_nodes(payload)
|
||||
end = next(n for n in nodes if n["id"] == _ieee(3))
|
||||
assert end["parent_id"] == _ieee(1)
|
||||
|
||||
def test_coordinator_has_no_incoming_edge(self) -> None:
|
||||
payload = _wrap([
|
||||
_node(1, controller=True, neighbors=[3]),
|
||||
_node(3, neighbors=[1]),
|
||||
])
|
||||
_, edges = parse_zwave_nodes(payload)
|
||||
assert all(e["target"] != _ieee(1) for e in edges)
|
||||
|
||||
def test_neighbor_to_unknown_node_dropped(self) -> None:
|
||||
payload = _wrap([_node(1, controller=True, neighbors=[99])])
|
||||
_, edges = parse_zwave_nodes(payload)
|
||||
assert edges == []
|
||||
|
||||
def test_no_coordinator_means_no_edges(self) -> None:
|
||||
payload = _wrap([_node(2, routing=True, neighbors=[3]), _node(3, neighbors=[2])])
|
||||
_, edges = parse_zwave_nodes(payload)
|
||||
assert edges == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_zwave_properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildProperties:
|
||||
def test_all_fields(self) -> None:
|
||||
props = build_zwave_properties("zwave-x-1", "Aeotec", "ZW100")
|
||||
keys = {p["key"]: p["value"] for p in props}
|
||||
assert keys == {"Z-Wave ID": "zwave-x-1", "Vendor": "Aeotec", "Model": "ZW100"}
|
||||
|
||||
def test_omits_empty(self) -> None:
|
||||
props = build_zwave_properties("zwave-x-1", None, None)
|
||||
assert [p["key"] for p in props] == ["Z-Wave ID"]
|
||||
|
||||
def test_defaults_hidden(self) -> None:
|
||||
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||
assert all(p["visible"] is False for p in props)
|
||||
|
||||
def test_no_lqi_row(self) -> None:
|
||||
props = build_zwave_properties("zwave-x-1", "V", "M")
|
||||
assert all(p["key"] != "LQI" for p in props)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_zwave_network (mocked MQTT round-trip via mqtt_common)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESPONSE_TOPIC = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
||||
|
||||
_SAMPLE_PAYLOAD = {
|
||||
"success": True,
|
||||
"result": [
|
||||
{"id": 1, "homeId": HOME, "isControllerNode": True, "name": "Controller"},
|
||||
{"id": 2, "homeId": HOME, "isRouting": True, "name": "Wall Plug", "neighbors": [1]},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_zwave_network_success() -> None:
|
||||
class _FakeMessage:
|
||||
topic = _RESPONSE_TOPIC
|
||||
payload = json.dumps(_SAMPLE_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, _t: str) -> None:
|
||||
pass
|
||||
|
||||
async def publish(self, _t: str, _p: str) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
return _FakeMessage()
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
nodes, edges = await fetch_zwave_network(mqtt_host="localhost", mqtt_port=1883)
|
||||
|
||||
assert any(n["type"] == "zwave_coordinator" for n in nodes)
|
||||
assert any(n["type"] == "zwave_router" for n in nodes)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_zwave_network_connection_error() -> None:
|
||||
class _FakeClient:
|
||||
async def __aenter__(self):
|
||||
raise Exception("Connection refused")
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
with pytest.raises(ConnectionError):
|
||||
await fetch_zwave_network(mqtt_host="bad", mqtt_port=1883)
|
||||
@@ -24,6 +24,20 @@ services:
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
mcp:
|
||||
image: ghcr.io/pouzor/homelable-mcp:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8001:8001"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
BACKEND_URL: "http://backend:8000"
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- homelable
|
||||
|
||||
volumes:
|
||||
backend_data:
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="128" height="128" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="16" height="16" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="512" height="512" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
|
||||
<!-- House body -->
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Floor line (subtle) -->
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
|
||||
<!-- Door -->
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
|
||||
<!-- Network lines (drawn under nodes) -->
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
|
||||
<!-- Center hub glow -->
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<!-- Center hub -->
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Left node -->
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Right node -->
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Top node -->
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="640" height="164" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="360" height="92" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" width="200" height="51" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<g transform="translate(4, 4)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
<text x="80" y="42" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="600" font-size="30" letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
<text x="81" y="58" font-family="Inter, system-ui, -apple-system, sans-serif" font-weight="400" font-size="11" fill="#8b949e" letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,48 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 280 72" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Icon (72×72, scaled from 64 viewBox) -->
|
||||
<g transform="translate(4, 4) scale(1)">
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</g>
|
||||
|
||||
<!-- Text -->
|
||||
<text x="80" y="42"
|
||||
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-weight="600"
|
||||
font-size="30"
|
||||
letter-spacing="-0.5">
|
||||
<tspan fill="#e6edf3">Home</tspan><tspan fill="#00d4ff">lable</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<text x="81" y="58"
|
||||
font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-weight="400"
|
||||
font-size="11"
|
||||
fill="#8b949e"
|
||||
letter-spacing="0.5">HomeLab Visualizer</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -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)_
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.8.3",
|
||||
"version": "2.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,9 +19,10 @@
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "^1.15.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
@@ -36,6 +37,11 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"overrides": {
|
||||
"hono": "^4.12.21",
|
||||
"esbuild": "^0.28.1",
|
||||
"form-data": "^4.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
@@ -57,7 +63,7 @@
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^7.3.5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`)
|
||||
@@ -4,8 +4,11 @@ import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { exportToPng } from '@/utils/export'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { ExportModal } from '@/components/modals/ExportModal'
|
||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
@@ -19,72 +22,145 @@ import { LoginPage } from '@/components/LoginPage'
|
||||
import { NodeModal } from '@/components/modals/NodeModal'
|
||||
import { EdgeModal } from '@/components/modals/EdgeModal'
|
||||
import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||
import { SearchModal } from '@/components/modals/SearchModal'
|
||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useDesignStore } from '@/stores/designStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { canvasApi } from '@/api/client'
|
||||
import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, nodes, edges, snapshotHistory, undo, redo, copySelectedNodes, pasteNodes } = useCanvasStore()
|
||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme } = useThemeStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
|
||||
|
||||
useStatusPolling()
|
||||
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [sidebarForceView, setSidebarForceView] = useState<'pending' | 'history' | undefined>(undefined)
|
||||
const [highlightPendingId, setHighlightPendingId] = useState<string | undefined>(undefined)
|
||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||
const [pendingModalStatus, setPendingModalStatus] = useState<'pending' | 'hidden'>('pending')
|
||||
const [pendingHighlightId, setPendingHighlightId] = useState<string | undefined>(undefined)
|
||||
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 [addNodeOpen, setAddNodeOpen] = useState(false)
|
||||
const [addGroupRectOpen, setAddGroupRectOpen] = useState(false)
|
||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope
|
||||
const handleSave = useCallback(async () => {
|
||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||
// Returns true on success, false on failure — the design-switch effect relies
|
||||
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
||||
try {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
if (STANDALONE) {
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme }))
|
||||
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle }))
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return
|
||||
return true
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
const edgesToSave = edges.map(serializeEdge)
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme } })
|
||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
return true
|
||||
} catch {
|
||||
toast.error('Save failed')
|
||||
return false
|
||||
}
|
||||
}, [nodes, edges, markSaved, activeTheme])
|
||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
|
||||
|
||||
// Keep a ref so the keydown handler always calls the latest version
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||
|
||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||
try {
|
||||
const res = await canvasApi.load(designId)
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n) => [n.id, true])
|
||||
)
|
||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
} catch {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
if (STANDALONE) return
|
||||
try {
|
||||
const res = await designsApi.list()
|
||||
const loadedDesigns = res.data
|
||||
setDesigns(loadedDesigns)
|
||||
const targetId = activeDesignId ?? loadedDesigns[0]?.id
|
||||
if (targetId) {
|
||||
setActiveDesign(targetId)
|
||||
await loadCanvasFromApi(targetId)
|
||||
}
|
||||
} catch {
|
||||
// If API fails (e.g. fresh DB with no designs), fall back to demo data
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas])
|
||||
|
||||
// Load canvas on auth (or immediately in standalone mode)
|
||||
useEffect(() => {
|
||||
if (STANDALONE) {
|
||||
try {
|
||||
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
|
||||
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 (custom_style) setCustomStyle(custom_style)
|
||||
loadCanvas(savedNodes, savedEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
@@ -95,37 +171,59 @@ export default function App() {
|
||||
return
|
||||
}
|
||||
if (!isAuthenticated) return
|
||||
canvasApi.load()
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
if (apiNodes.length > 0) {
|
||||
// Build a map of proxmox container mode to know if children should be nested
|
||||
const proxmoxContainerMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
)
|
||||
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)
|
||||
loadCanvas(rfNodes, rfEdges)
|
||||
} else {
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
})
|
||||
.catch(() => loadCanvas(demoNodes, demoEdges))
|
||||
}, [isAuthenticated, loadCanvas, setTheme])
|
||||
loadDesignsAndCanvas()
|
||||
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change
|
||||
|
||||
// Reload canvas when active design changes (after initial load)
|
||||
const initialLoadDone = useRef(false)
|
||||
const prevDesignRef = useRef<string | null>(null)
|
||||
// Set while we programmatically revert activeDesignId after a failed save, so
|
||||
// the re-entrant effect run skips save/load and just re-syncs the refs.
|
||||
const revertingRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (revertingRef.current) {
|
||||
revertingRef.current = false
|
||||
prevDesignRef.current = activeDesignId
|
||||
return
|
||||
}
|
||||
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) {
|
||||
const oldId = prevDesignRef.current
|
||||
// If the previous design was deleted (no longer in the list), don't try to
|
||||
// save into it — just load the newly-selected design.
|
||||
const oldStillExists = oldId ? useDesignStore.getState().designs.some((d) => d.id === oldId) : false
|
||||
if (oldId && oldId !== activeDesignId && oldStillExists) {
|
||||
// Save current (old) canvas data under the old design ID before switching.
|
||||
// We call handleSave directly (not via ref) so it runs in this effect's
|
||||
// closure where activeDesignId is already the NEW value — the override
|
||||
// ensures data is stored under the correct design_id.
|
||||
const targetId = activeDesignId
|
||||
handleSave(oldId).then((ok) => {
|
||||
if (ok) {
|
||||
loadCanvasFromApi(targetId)
|
||||
} else {
|
||||
// Save failed: don't load the new design — that would overwrite the
|
||||
// unsaved in-memory canvas. Revert the selection back to the old
|
||||
// design so the UI matches the data still on screen.
|
||||
toast.error('Switch cancelled — unsaved changes kept')
|
||||
revertingRef.current = true
|
||||
setActiveDesign(oldId)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
loadCanvasFromApi(activeDesignId)
|
||||
}
|
||||
}
|
||||
if (activeDesignId) {
|
||||
prevDesignRef.current = activeDesignId
|
||||
initialLoadDone.current = true
|
||||
}
|
||||
}, [activeDesignId])
|
||||
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
const redoRef = useRef(redo)
|
||||
const copyRef = useRef(copySelectedNodes)
|
||||
const pasteRef = useRef(pasteNodes)
|
||||
useEffect(() => { undoRef.current = undo }, [undo])
|
||||
useEffect(() => { redoRef.current = redo }, [redo])
|
||||
useEffect(() => { copyRef.current = copySelectedNodes }, [copySelectedNodes])
|
||||
useEffect(() => { pasteRef.current = pasteNodes }, [pasteNodes])
|
||||
|
||||
// Global keyboard shortcuts
|
||||
useEffect(() => {
|
||||
@@ -139,8 +237,8 @@ export default function App() {
|
||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||
if (ctrl && e.key === 'c' && !isInput) { copyRef.current(); return }
|
||||
if (ctrl && e.key === 'v' && !isInput) { pasteRef.current(); return }
|
||||
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||
// nodes under the cursor / viewport center.
|
||||
if (e.key === '?' && !isInput) { setShortcutsOpen(true); return }
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
@@ -150,20 +248,26 @@ export default function App() {
|
||||
const handleAddNode = useCallback((data: Partial<NodeData>) => {
|
||||
snapshotHistory()
|
||||
const id = generateUUID()
|
||||
const isProxmox = data.type === 'proxmox'
|
||||
const isContainerNode = data.container_mode === true
|
||||
const parentNode = data.parent_id ? nodes.find((n) => n.id === data.parent_id) : null
|
||||
// Children position is relative to parent; place near top-left with padding
|
||||
const position = parentNode
|
||||
? { x: 20, y: 50 }
|
||||
: { x: 300, y: 300 }
|
||||
// Only nest when the parent is an actual container. For a non-container
|
||||
// parent the LXC/VM stays a free node (linked by a virtual edge) — setting
|
||||
// extent:'parent' on a non-container would trap it inside the parent's tiny
|
||||
// bounding box with no way to drag it out (issue #205 follow-up).
|
||||
const nestInParent = !!parentNode?.data.container_mode
|
||||
// Seed an ABSOLUTE position near the container's top-left; addNode converts
|
||||
// it to container-relative. addNode is the single authority for parentId /
|
||||
// extent, so we don't set them here.
|
||||
const position = nestInParent && parentNode
|
||||
? { x: parentNode.position.x + 20, y: parentNode.position.y + 50 }
|
||||
: getCenteredPosition(isContainerNode ? 300 : 0, isContainerNode ? 200 : 0)
|
||||
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
type: data.type ?? 'generic',
|
||||
position,
|
||||
data: { status: 'unknown', services: [], ...data } as NodeData,
|
||||
...(data.parent_id ? { parentId: data.parent_id, extent: 'parent' as const } : {}),
|
||||
...(isProxmox ? { width: 300, height: 200 } : {}),
|
||||
...(isContainerNode ? { width: 300, height: 200 } : {}),
|
||||
}
|
||||
addNode(newNode)
|
||||
toast.success(`Added "${data.label}"`)
|
||||
@@ -175,7 +279,7 @@ export default function App() {
|
||||
const newNode: Node<NodeData> = {
|
||||
id,
|
||||
type: 'groupRect',
|
||||
position: { x: 200, y: 200 },
|
||||
position: getCenteredPosition(360, 240),
|
||||
data: {
|
||||
label: data.label,
|
||||
type: 'groupRect',
|
||||
@@ -225,6 +329,67 @@ export default function App() {
|
||||
setEditingGroupRectId(null)
|
||||
}, [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: getCenteredPosition(200, 60),
|
||||
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(() => {
|
||||
if (!editingGroupRectId) return
|
||||
snapshotHistory()
|
||||
@@ -241,13 +406,13 @@ export default function App() {
|
||||
snapshotHistory()
|
||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||
updateNode(editNodeId, data)
|
||||
// If proxmox container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (data.type === 'proxmox' && typeof data.container_mode === 'boolean') {
|
||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
||||
if (typeof data.container_mode === 'boolean') {
|
||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||
}
|
||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||
const nodeType = data.type ?? existingNode?.data.type
|
||||
if ((nodeType === 'lxc' || nodeType === 'vm') && 'parent_id' in data) {
|
||||
if ((nodeType === 'lxc' || nodeType === 'vm' || nodeType === 'docker_container') && 'parent_id' in data) {
|
||||
const oldParentId = existingNode?.data.parent_id ?? null
|
||||
const newParentId = data.parent_id ?? null
|
||||
if (oldParentId !== newParentId) {
|
||||
@@ -282,8 +447,11 @@ export default function App() {
|
||||
const handleExportMd = useCallback(async () => {
|
||||
const md = generateMarkdownTable(nodes)
|
||||
if (!md) { toast.error('No nodes to export'); return }
|
||||
await navigator.clipboard.writeText(md)
|
||||
toast.success('Markdown table copied to clipboard')
|
||||
if (await copyToClipboard(md)) {
|
||||
toast.success('Markdown table copied to clipboard')
|
||||
} else {
|
||||
toast.error('Markdown copy failed')
|
||||
}
|
||||
}, [nodes])
|
||||
|
||||
const handleExportYaml = useCallback(() => {
|
||||
@@ -305,17 +473,132 @@ export default function App() {
|
||||
}
|
||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
// Open the read-only live view of the currently active design in a new tab.
|
||||
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||
const handleViewOnly = useCallback(async () => {
|
||||
if (STANDALONE) {
|
||||
window.open('/view', '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await liveviewApi.getConfig()
|
||||
if (!res.data.enabled || !res.data.key) {
|
||||
toast.error('Live view is disabled — set LIVEVIEW_KEY in the backend .env')
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams({ key: res.data.key })
|
||||
if (activeDesignId) params.set('design', activeDesignId)
|
||||
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
|
||||
} catch {
|
||||
toast.error('Failed to open live view')
|
||||
}
|
||||
}, [activeDesignId])
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
|
||||
if (!el) { toast.error('Canvas not ready'); return }
|
||||
try {
|
||||
await exportToPng(el)
|
||||
toast.success('Exported as PNG')
|
||||
} catch {
|
||||
toast.error('Export failed')
|
||||
}
|
||||
setExportModalOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleZigbeeAddToCanvas = useCallback((zigbeeNodes: ZigbeeNode[], zigbeeEdges: ZigbeeEdge[]) => {
|
||||
snapshotHistory()
|
||||
// Place nodes in a grid centred on the visible canvas.
|
||||
const COLS = 4
|
||||
const SPACING_X = 170
|
||||
const SPACING_Y = 100
|
||||
const cols = Math.min(COLS, zigbeeNodes.length)
|
||||
const rows = Math.ceil(zigbeeNodes.length / COLS)
|
||||
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||
zigbeeNodes.forEach((zn, i) => {
|
||||
const id = zn.id
|
||||
const col = i % COLS
|
||||
const row = Math.floor(i / COLS)
|
||||
const position = { x: origin.x + col * SPACING_X, y: origin.y + 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 handleZwaveAddToCanvas = useCallback((zwaveNodes: ZwaveNode[], zwaveEdges: ZwaveEdge[]) => {
|
||||
snapshotHistory()
|
||||
const COLS = 4
|
||||
const SPACING_X = 170
|
||||
const SPACING_Y = 100
|
||||
const cols = Math.min(COLS, zwaveNodes.length)
|
||||
const rows = Math.ceil(zwaveNodes.length / COLS)
|
||||
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||
zwaveNodes.forEach((zn, i) => {
|
||||
const id = zn.id
|
||||
const col = i % COLS
|
||||
const row = Math.floor(i / COLS)
|
||||
const position = { x: origin.x + col * SPACING_X, y: origin.y + 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.model ? { os: zn.model } : {}),
|
||||
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
})
|
||||
// Add IoT edges between Z-Wave devices: parent bottom -> child top
|
||||
zwaveEdges.forEach((ze) => {
|
||||
onConnect({
|
||||
source: ze.source,
|
||||
sourceHandle: 'bottom',
|
||||
target: ze.target,
|
||||
targetHandle: 'top-t',
|
||||
type: 'iot',
|
||||
} as unknown as import('@xyflow/react').Connection)
|
||||
})
|
||||
const importedIds = new Set(zwaveNodes.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) => {
|
||||
setPendingConnection(connection)
|
||||
}, [])
|
||||
@@ -324,16 +607,18 @@ export default function App() {
|
||||
if (!pendingConnection) return
|
||||
snapshotHistory()
|
||||
onConnect({ ...pendingConnection, ...edgeData } as unknown as Connection)
|
||||
// When a virtual edge is drawn between LXC/VM (top) and Proxmox (bottom), sync parent_id
|
||||
// When a virtual edge is drawn between a child node and a container node, sync parent_id
|
||||
if (edgeData.type === 'virtual') {
|
||||
const src = nodes.find((n) => n.id === pendingConnection.source)
|
||||
const tgt = nodes.find((n) => n.id === pendingConnection.target)
|
||||
const srcType = src?.data.type
|
||||
const tgtType = tgt?.data.type
|
||||
if ((srcType === 'lxc' || srcType === 'vm') && tgtType === 'proxmox') {
|
||||
updateNode(pendingConnection.source, { parent_id: pendingConnection.target })
|
||||
} else if (srcType === 'proxmox' && (tgtType === 'lxc' || tgtType === 'vm')) {
|
||||
updateNode(pendingConnection.target, { parent_id: pendingConnection.source })
|
||||
if (src && tgt) {
|
||||
const assignment = resolveVirtualEdgeParent(
|
||||
{ id: src.id, type: src.data.type as NodeData['type'] },
|
||||
{ id: tgt.id, type: tgt.data.type as NodeData['type'] },
|
||||
)
|
||||
if (assignment) {
|
||||
updateNode(assignment.childId, { parent_id: assignment.parentId })
|
||||
}
|
||||
}
|
||||
}
|
||||
setPendingConnection(null)
|
||||
@@ -343,6 +628,15 @@ export default function App() {
|
||||
setEditEdgeId(edge.id)
|
||||
}, [])
|
||||
|
||||
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])
|
||||
|
||||
const handleEdgeUpdate = useCallback((data: EdgeData) => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
@@ -357,6 +651,13 @@ export default function App() {
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, deleteEdge, snapshotHistory])
|
||||
|
||||
const handleClearWaypoints = useCallback(() => {
|
||||
if (!editEdgeId) return
|
||||
snapshotHistory()
|
||||
updateEdge(editEdgeId, { waypoints: [] })
|
||||
setEditEdgeId(null)
|
||||
}, [editEdgeId, updateEdge, snapshotHistory])
|
||||
|
||||
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
|
||||
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
|
||||
|
||||
@@ -369,11 +670,14 @@ export default function App() {
|
||||
<Sidebar
|
||||
onAddNode={() => setAddNodeOpen(true)}
|
||||
onAddGroupRect={() => setAddGroupRectOpen(true)}
|
||||
onAddText={() => setAddTextOpen(true)}
|
||||
onScan={() => setScanConfigOpen(true)}
|
||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||
onSave={handleSave}
|
||||
onNodeApproved={setEditNodeId}
|
||||
forceView={sidebarForceView}
|
||||
highlightPendingId={highlightPendingId}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||
onOpenPending={openPendingModal}
|
||||
/>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<Toolbar
|
||||
@@ -387,21 +691,18 @@ export default function App() {
|
||||
onExportMd={handleExportMd}
|
||||
onExportYaml={handleExportYaml}
|
||||
onImportYaml={handleImportYaml}
|
||||
onViewOnly={handleViewOnly}
|
||||
/>
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<div ref={canvasRef} className="flex-1 min-w-0 h-full">
|
||||
<CanvasContainer
|
||||
onConnect={handleEdgeConnect}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={snapshotHistory}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
onRequestAddToGroup={setPendingGroupAdd}
|
||||
onRequestAddToContainer={setPendingContainerAdd}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
</div>
|
||||
{(selectedNodeId || selectedNodeIds.length > 1) && <DetailPanel onEdit={handleEditNode} />}
|
||||
@@ -410,11 +711,12 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<NodeModal
|
||||
key={addNodeOpen ? 'add-open' : 'add-closed'}
|
||||
open={addNodeOpen}
|
||||
onClose={() => setAddNodeOpen(false)}
|
||||
onSubmit={handleAddNode}
|
||||
title="Add Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||
/>
|
||||
|
||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||
@@ -425,7 +727,25 @@ export default function App() {
|
||||
onSubmit={handleUpdateNode}
|
||||
initial={editNode?.data}
|
||||
title="Edit Node"
|
||||
proxmoxNodes={nodes.filter((n) => n.type === 'proxmox').map((n) => ({ id: n.id, label: n.data.label }))}
|
||||
parentCandidates={(() => {
|
||||
const descendants = new Set<string>()
|
||||
if (editNodeId) {
|
||||
const queue = [editNodeId]
|
||||
while (queue.length) {
|
||||
const id = queue.shift()!
|
||||
for (const n of nodes) {
|
||||
if (n.data.parent_id === id && !descendants.has(n.id)) {
|
||||
descendants.add(n.id)
|
||||
queue.push(n.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nodes
|
||||
.filter((n) => !descendants.has(n.id))
|
||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||
})()}
|
||||
currentNodeId={editNodeId ?? undefined}
|
||||
/>
|
||||
|
||||
<EdgeModal
|
||||
@@ -446,6 +766,7 @@ export default function App() {
|
||||
onClose={() => setEditEdgeId(null)}
|
||||
onSubmit={handleEdgeUpdate}
|
||||
onDelete={handleEdgeDelete}
|
||||
onClearWaypoints={handleClearWaypoints}
|
||||
initial={editEdge?.data}
|
||||
title="Edit Link"
|
||||
/>
|
||||
@@ -456,12 +777,39 @@ export default function App() {
|
||||
onClose={() => setScanConfigOpen(false)}
|
||||
onScanNow={() => {
|
||||
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 && (
|
||||
<ZwaveImportModal
|
||||
open={zwaveImportOpen}
|
||||
onClose={() => setZwaveImportOpen(false)}
|
||||
onAddToCanvas={handleZwaveAddToCanvas}
|
||||
onPendingImported={() => {
|
||||
toast.success('Z-Wave import started — check Scan History for results')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!STANDALONE && (
|
||||
<ScanHistoryModal
|
||||
open={scanHistoryOpen}
|
||||
onClose={() => setScanHistoryOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GroupRectModal
|
||||
open={addGroupRectOpen}
|
||||
onClose={() => setAddGroupRectOpen(false)}
|
||||
@@ -497,6 +845,37 @@ export default function App() {
|
||||
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 */}
|
||||
<ThemeModal
|
||||
key={themeModalOpen ? 'theme-open' : 'theme-closed'}
|
||||
@@ -507,17 +886,50 @@ export default function App() {
|
||||
<SearchModal
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenPending={(deviceId) => {
|
||||
setHighlightPendingId(undefined)
|
||||
setSidebarForceView(undefined)
|
||||
setTimeout(() => {
|
||||
setHighlightPendingId(deviceId)
|
||||
setSidebarForceView('pending')
|
||||
}, 0)
|
||||
}}
|
||||
onOpenPending={(deviceId) => openPendingModal(deviceId)}
|
||||
/>
|
||||
<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
|
||||
open={exportModalOpen}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
getElement={() => canvasRef.current?.querySelector<HTMLElement>('.react-flow') ?? null}
|
||||
/>
|
||||
|
||||
<Toaster theme="dark" position="bottom-right" />
|
||||
</ReactFlowProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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'], design_id: undefined })
|
||||
mod.scanApi.bulkApprove(['a'], 'design-9')
|
||||
expect(api.post).toHaveBeenCalledWith('/scan/pending/bulk-approve', { device_ids: ['a'], design_id: 'design-9' })
|
||||
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)
|
||||
})
|
||||
|
||||
it('zwaveApi.testConnection/importNetwork/importToPending', () => {
|
||||
const cfg = { mqtt_host: 'h', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt' }
|
||||
mod.zwaveApi.testConnection(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/test-connection', cfg)
|
||||
mod.zwaveApi.importNetwork(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/import', cfg)
|
||||
mod.zwaveApi.importToPending(cfg)
|
||||
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,16 @@ export const authApi = {
|
||||
}
|
||||
|
||||
export const canvasApi = {
|
||||
load: () => api.get('/canvas'),
|
||||
load: (design_id?: string) => {
|
||||
const params = design_id ? { design_id } : {}
|
||||
return api.get('/canvas', { params })
|
||||
},
|
||||
save: (payload: {
|
||||
nodes: object[]
|
||||
edges: object[]
|
||||
viewport: object
|
||||
custom_style?: object | null
|
||||
design_id?: string | null
|
||||
}) => api.post('/canvas/save', payload),
|
||||
}
|
||||
|
||||
@@ -48,24 +53,163 @@ export const edgesApi = {
|
||||
}
|
||||
|
||||
export const liveviewApi = {
|
||||
load: (key: string) => publicApi.get('/liveview', { params: { key } }),
|
||||
load: (key: string, design?: string) =>
|
||||
publicApi.get('/liveview', { params: { key, ...(design ? { design_id: design } : {}) } }),
|
||||
getConfig: () => api.get<{ enabled: boolean; key: string | null }>('/liveview/config'),
|
||||
}
|
||||
|
||||
export interface DeepScanConfig {
|
||||
http_ranges: string[]
|
||||
http_probe_enabled: boolean
|
||||
verify_tls: boolean
|
||||
}
|
||||
|
||||
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
||||
|
||||
export const scanApi = {
|
||||
trigger: () => api.post('/scan/trigger'),
|
||||
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
|
||||
pending: () => api.get('/scan/pending'),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
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`),
|
||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||
bulkApprove: (ids: string[], designId?: string | null) =>
|
||||
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, design_id: designId ?? undefined }),
|
||||
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`),
|
||||
getConfig: () => api.get<{ ranges: string[] }>('/scan/config'),
|
||||
saveConfig: (data: { ranges: string[] }) => api.post('/scan/config', data),
|
||||
getConfig: () => api.get<ScanConfigData>('/scan/config'),
|
||||
saveConfig: (data: ScanConfigData) => api.post('/scan/config', data),
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
interval_seconds: number
|
||||
service_check_enabled: boolean
|
||||
service_check_interval: number
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => api.get<{ interval_seconds: number }>('/settings'),
|
||||
save: (data: { interval_seconds: number }) => api.post<{ interval_seconds: number }>('/settings', data),
|
||||
get: () => api.get<AppSettings>('/settings'),
|
||||
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||
}
|
||||
|
||||
export const designsApi = {
|
||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||
api.post<import('@/types').Design>('/designs', data),
|
||||
update: (id: string, data: { name?: string; icon?: string }) =>
|
||||
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||
}
|
||||
|
||||
export const zigbeeApi = {
|
||||
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),
|
||||
}
|
||||
|
||||
export const zwaveApi = {
|
||||
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 }>('/zwave/test-connection', data),
|
||||
|
||||
importNetwork: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
prefix?: string
|
||||
gateway_name?: string
|
||||
mqtt_tls?: boolean
|
||||
mqtt_tls_insecure?: boolean
|
||||
}) =>
|
||||
api.post<{
|
||||
nodes: import('@/components/zwave/types').ZwaveNode[]
|
||||
edges: import('@/components/zwave/types').ZwaveEdge[]
|
||||
device_count: number
|
||||
}>('/zwave/import', data),
|
||||
|
||||
importToPending: (data: {
|
||||
mqtt_host: string
|
||||
mqtt_port: number
|
||||
mqtt_username?: string
|
||||
mqtt_password?: string
|
||||
prefix?: string
|
||||
gateway_name?: 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
|
||||
}>('/zwave/import-pending', data),
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* Clicking a node with an IP opens http://<ip> in a new tab.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
ReactFlowProvider,
|
||||
ReactFlow,
|
||||
@@ -28,8 +28,9 @@ import { THEMES } from '@/utils/themes'
|
||||
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeData, CustomStyleDef } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
@@ -40,6 +41,8 @@ function LiveViewCanvas() {
|
||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||
const theme = THEMES[activeTheme]
|
||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||
// - standalone → always ready (localStorage, no key required)
|
||||
@@ -65,17 +68,24 @@ function LiveViewCanvas() {
|
||||
}
|
||||
|
||||
// Already handled synchronously in useState initializer
|
||||
const key = new URLSearchParams(window.location.search).get('key')
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const key = search.get('key')
|
||||
if (!key) return
|
||||
// Optional ?design=<id> selects which canvas to render; backend falls back
|
||||
// to the first design when omitted.
|
||||
const design = search.get('design') ?? undefined
|
||||
|
||||
liveviewApi.load(key)
|
||||
liveviewApi.load(key, design)
|
||||
.then((res) => {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const proxmoxMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n: ApiNode) => [n.id, true])
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
@@ -87,7 +97,7 @@ function LiveViewCanvas() {
|
||||
const detail: string = err.response.data?.detail ?? ''
|
||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||
})
|
||||
}, [loadCanvas])
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
@@ -103,6 +113,18 @@ function LiveViewCanvas() {
|
||||
if (ip) window.open(`http://${ip}`, '_blank', 'noopener,noreferrer')
|
||||
}, [])
|
||||
|
||||
// Apply collapse-state filtering — same pipeline the editor canvas uses,
|
||||
// so a collapsed group/zone hides its contents in live view too.
|
||||
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
|
||||
const visibleNodes = useMemo(
|
||||
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
|
||||
[nodes, collapseInfo],
|
||||
)
|
||||
const visibleEdges = useMemo(
|
||||
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
|
||||
[edges, nodes, collapseInfo],
|
||||
)
|
||||
|
||||
if (viewState === 'loading') {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-[#8b949e]">
|
||||
@@ -131,8 +153,8 @@ function LiveViewCanvas() {
|
||||
return (
|
||||
<div className="w-full h-screen" style={{ background: theme.colors.canvasBackground }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodesDraggable={false}
|
||||
@@ -140,6 +162,8 @@ function LiveViewCanvas() {
|
||||
elementsSelectable={false}
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
maxZoom={2.5}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
onNodeClick={onNodeClick}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
// ── Capture the props ReactFlow is rendered with ──────────────────────────
|
||||
const rfPropsSpy = vi.fn()
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: (props: unknown) => {
|
||||
rfPropsSpy(props)
|
||||
return <div data-testid="react-flow" />
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
vi.mock('@/api/client', () => ({ liveviewApi: { load: vi.fn() } }))
|
||||
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import LiveView from '../LiveView'
|
||||
|
||||
function setSearch(params: string) {
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { ...window.location, search: params, pathname: '/view' },
|
||||
})
|
||||
}
|
||||
|
||||
/** Build a /liveview API response with the given nodes/edges. */
|
||||
const apiResponse = (nodes: unknown[], edges: unknown[] = []) => ({
|
||||
data: { nodes, edges, viewport: { x: 0, y: 0, zoom: 1 } },
|
||||
})
|
||||
|
||||
const apiNode = (
|
||||
id: string,
|
||||
parent_id?: string,
|
||||
collapsed?: boolean,
|
||||
type = 'server',
|
||||
) => ({
|
||||
id,
|
||||
type,
|
||||
label: id,
|
||||
status: 'online',
|
||||
services: [],
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
parent_id: parent_id ?? null,
|
||||
container_mode: type === 'group',
|
||||
custom_colors: collapsed !== undefined ? { collapsed } : null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: '2024-01-01T00:00:00Z',
|
||||
})
|
||||
|
||||
describe('LiveView — applies collapse filter to the rendered canvas', () => {
|
||||
beforeEach(() => {
|
||||
rfPropsSpy.mockClear()
|
||||
setSearch('?key=valid')
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
})
|
||||
|
||||
it('hides children of a collapsed group container in view-only mode', async () => {
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||
apiResponse([apiNode('g1', undefined, true, 'group'), apiNode('c1', 'g1')]),
|
||||
)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => {
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||
| { nodes: Node<NodeData>[] }
|
||||
| undefined
|
||||
expect(last?.nodes.length).toBeGreaterThan(0)
|
||||
})
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
}
|
||||
const ids = last.nodes.map((n) => n.id)
|
||||
expect(ids).toContain('g1')
|
||||
expect(ids).not.toContain('c1')
|
||||
})
|
||||
|
||||
it('shows children when the group is expanded', async () => {
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(
|
||||
apiResponse([apiNode('g1', undefined, false, 'group'), apiNode('c1', 'g1')]),
|
||||
)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => {
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1]?.[0] as
|
||||
| { nodes: Node<NodeData>[] }
|
||||
| undefined
|
||||
expect(last?.nodes.length).toBeGreaterThan(1)
|
||||
})
|
||||
const last = rfPropsSpy.mock.calls[rfPropsSpy.mock.calls.length - 1][0] as {
|
||||
nodes: Node<NodeData>[]
|
||||
}
|
||||
const ids = last.nodes.map((n) => n.id)
|
||||
expect(ids).toContain('g1')
|
||||
expect(ids).toContain('c1')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,24 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||
|
||||
// Capture props passed to ReactFlow so we can assert zoom bounds etc.
|
||||
let rfProps: Record<string, unknown> = {}
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: () => <div data-testid="react-flow" />,
|
||||
ReactFlow: (props: Record<string, unknown>) => {
|
||||
rfProps = props
|
||||
return <div data-testid="react-flow" />
|
||||
},
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}))
|
||||
vi.mock('@xyflow/react/dist/style.css', () => ({}))
|
||||
@@ -47,6 +55,7 @@ const canvasPayload = {
|
||||
|
||||
describe('LiveView (non-standalone)', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
vi.mocked(liveviewApi.load).mockReset()
|
||||
useCanvasStore.setState({ nodes: [], edges: [] })
|
||||
})
|
||||
@@ -109,7 +118,26 @@ describe('LiveView (non-standalone)', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key')
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', undefined)
|
||||
})
|
||||
|
||||
it('forwards ?design=<id> to the API so a specific canvas is loaded', async () => {
|
||||
setSearch('?key=correct-key&design=elec-123')
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(liveviewApi.load).toHaveBeenCalledWith('correct-key', 'elec-123')
|
||||
})
|
||||
|
||||
it('allows zooming out to 0.25 so large infra fits (matches the editor)', async () => {
|
||||
setSearch('?key=correct-key')
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(canvasPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
// Without an explicit minZoom, React Flow defaults to 0.5 and big canvases
|
||||
// can't zoom out far enough to fit.
|
||||
expect(rfProps.minZoom).toBe(0.25)
|
||||
expect(rfProps.maxZoom).toBe(2.5)
|
||||
})
|
||||
|
||||
it('loads nodes into the canvas store on success', async () => {
|
||||
@@ -123,6 +151,55 @@ describe('LiveView (non-standalone)', () => {
|
||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||
|
||||
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||
setSearch('?key=valid')
|
||||
const nestedPayload = {
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||
expect(ctr?.parentId).toBe('host')
|
||||
expect(ctr?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||
|
||||
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||
setSearch('?key=valid')
|
||||
const styledPayload = {
|
||||
data: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||
})
|
||||
|
||||
// ── No editing props passed ───────────────────────────────────────────────
|
||||
|
||||
it('does not show any Access Denied when key is valid', async () => {
|
||||
@@ -143,6 +220,7 @@ const XYFLOW_MOCK = {
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
ConnectionMode: { Loose: 'loose' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({ fitView: vi.fn() }),
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -17,27 +17,74 @@ import '@xyflow/react/dist/style.css'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
import { THEMES } from '@/utils/themes'
|
||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||
import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
import { AlignmentGuides } from './AlignmentGuides'
|
||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
|
||||
interface CanvasContainerProps {
|
||||
onConnect?: (connection: Connection) => void
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||
onOpenPending?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDragStart, onOpenPending }: CanvasContainerProps) {
|
||||
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenPending }: CanvasContainerProps) {
|
||||
const [lassoMode, setLassoMode] = useState(true)
|
||||
const {
|
||||
nodes, edges,
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
copySelectedNodes, pasteNodes,
|
||||
} = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||
|
||||
// Track the last cursor position over the canvas so paste lands under it.
|
||||
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||
}, [])
|
||||
|
||||
// Expose the visible-canvas centre (in flow coords) to add-node handlers that
|
||||
// live outside ReactFlowProvider, so new nodes land where the user is looking.
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
setViewportCenterProjector(() => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect()
|
||||
const screen = rect
|
||||
? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
|
||||
: { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||
return screenToFlowPosition(screen)
|
||||
})
|
||||
return () => setViewportCenterProjector(null)
|
||||
}, [screenToFlowPosition])
|
||||
|
||||
// Copy / paste shortcuts. Registered here (inside ReactFlowProvider) so paste
|
||||
// can project the cursor / viewport center into flow coordinates.
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!(e.ctrlKey || e.metaKey)) return
|
||||
const el = e.target as HTMLElement
|
||||
const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable
|
||||
if (isInput) return
|
||||
if (e.key === 'c') {
|
||||
copySelectedNodes()
|
||||
} else if (e.key === 'v') {
|
||||
const screen = cursorRef.current ?? { x: window.innerWidth / 2, y: window.innerHeight / 2 }
|
||||
pasteNodes(screenToFlowPosition(screen))
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [copySelectedNodes, pasteNodes, screenToFlowPosition])
|
||||
|
||||
// Fit view after canvas loads (fitViewPending is set by loadCanvas)
|
||||
useEffect(() => {
|
||||
@@ -52,6 +99,17 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
|
||||
// Filter nodes and edges based on collapsed state (memoized — O(n)).
|
||||
const collapseInfo = useMemo(() => computeCollapseInfo(nodes), [nodes])
|
||||
const visibleNodes = useMemo(
|
||||
() => nodes.filter((n) => collapseInfo.visibleIds.has(n.id)),
|
||||
[nodes, collapseInfo],
|
||||
)
|
||||
const visibleEdges = useMemo(
|
||||
() => rewireEdgesForCollapse(edges, nodes, collapseInfo.visibleIds, collapseInfo.hiddenBy),
|
||||
[edges, nodes, collapseInfo],
|
||||
)
|
||||
|
||||
const onNodeClick = useCallback((e: React.MouseEvent, node: Node<NodeData>) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
setSelectedNode(null)
|
||||
@@ -68,41 +126,81 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onEdgeDoubleClick?.(edge)
|
||||
}, [onEdgeDoubleClick])
|
||||
|
||||
const handleNodeDoubleClick = useCallback((_: React.MouseEvent, node: Node<NodeData>) => {
|
||||
onNodeDoubleClick?.(node)
|
||||
}, [onNodeDoubleClick])
|
||||
|
||||
const handleBeforeDelete = useCallback(async () => {
|
||||
snapshotHistory()
|
||||
return true
|
||||
}, [snapshotHistory])
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(connection: { source: string | null; target: string | null }) => connection.source !== connection.target,
|
||||
[]
|
||||
)
|
||||
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||
// before the alignment snap so detection uses the dropped position.
|
||||
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||
if (dragNode && !dragNode.parentId &&
|
||||
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||
const intersecting = getIntersectingNodes(dragNode)
|
||||
const group = intersecting.find((n) => n.data.type === 'group')
|
||||
if (group) {
|
||||
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||
} else {
|
||||
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||
}
|
||||
}
|
||||
onNodeDragStop(event, dragNode, dragNodes)
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
|
||||
|
||||
return (
|
||||
<div className="w-full h-full" style={{ background: theme.colors.canvasBackground }}>
|
||||
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnectProp}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onEdgeDoubleClick={handleEdgeDoubleClick}
|
||||
onNodeDoubleClick={handleNodeDoubleClick}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
onBeforeDelete={async () => { snapshotHistory(); return true }}
|
||||
onBeforeDelete={handleBeforeDelete}
|
||||
selectionOnDrag={lassoMode}
|
||||
panOnDrag={lassoMode ? [1, 2] : true}
|
||||
panActivationKeyCode="Space"
|
||||
selectionMode={SelectionMode.Partial}
|
||||
multiSelectionKeyCode={['Meta', 'Control']}
|
||||
minZoom={0.25}
|
||||
maxZoom={2.5}
|
||||
snapToGrid
|
||||
snapGrid={[8, 8]}
|
||||
colorMode={theme.colors.reactFlowColorMode}
|
||||
elevateNodesOnSelect={false}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
isValidConnection={(connection) => connection.source !== connection.target}
|
||||
isValidConnection={isValidConnection}
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
gap={16}
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<AlignmentGuides guides={guides} />
|
||||
<Controls>
|
||||
<ControlButton
|
||||
onClick={() => setLassoMode((m) => !m)}
|
||||
|
||||