Compare commits
338
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eec0b3f9a | ||
|
|
b76519b0dd | ||
|
|
a2870a6e29 | ||
|
|
d6308a5573 | ||
|
|
fafbaeffcd | ||
|
|
824aa5d044 | ||
|
|
416b5fd1cb | ||
|
|
fcc2927b69 | ||
|
|
be46c21dd4 | ||
|
|
1c9d5791b7 | ||
|
|
0c7fd3c127 | ||
|
|
19758b2ba1 | ||
|
|
1f6812ef2a | ||
|
|
9db8b2a7a9 | ||
|
|
d40f73b85c | ||
|
|
71e29362aa | ||
|
|
7a5762e1a0 | ||
|
|
be2c5ce7e2 | ||
|
|
b4b22cc68f | ||
|
|
21465eee65 | ||
|
|
9b77e43ca1 | ||
|
|
e53197d2cb | ||
|
|
4393ac92c6 | ||
|
|
59079d90ca | ||
|
|
26e18dafdd | ||
|
|
c796cb39bd | ||
|
|
a93b0408b8 | ||
|
|
e40ac25b20 | ||
|
|
e93aef3871 | ||
|
|
79b682148e | ||
|
|
5040b70073 | ||
|
|
1343391b0d | ||
|
|
ecc3cb8364 | ||
|
|
9954fccf75 | ||
|
|
c263f839a8 | ||
|
|
e22152d2d3 | ||
|
|
57df3b9198 | ||
|
|
5617f0800f | ||
|
|
cd6402a680 | ||
|
|
4d2128c494 | ||
|
|
84360ffc50 | ||
|
|
56df462a8f | ||
|
|
06ccd354c5 | ||
|
|
c63bfd3859 | ||
|
|
2af6f728d7 | ||
|
|
b1186bd7dd | ||
|
|
a70fc248bf | ||
|
|
ff740f7466 | ||
|
|
3138cda378 | ||
|
|
0640704678 | ||
|
|
b129200929 | ||
|
|
e1f8850d1f | ||
|
|
9bf90cbbed | ||
|
|
7067bd76d4 | ||
|
|
8cafeee743 | ||
|
|
cfa296ef87 | ||
|
|
c6c7624084 | ||
|
|
e2da642554 | ||
|
|
a0d16e1454 | ||
|
|
33df37b691 | ||
|
|
f07f43686e | ||
|
|
3bde741fd2 | ||
|
|
e8ea6857bc | ||
|
|
42774fc138 | ||
|
|
8f273e0201 | ||
|
|
3c8e463dd9 | ||
|
|
c4017c7634 | ||
|
|
a88688170a | ||
|
|
aaccb50b00 | ||
|
|
34758c9850 | ||
|
|
4d2284dc37 | ||
|
|
9f974f2fcf | ||
|
|
37ad9c6933 | ||
|
|
99e7b9a343 | ||
|
|
f061db645b | ||
|
|
fd96b7d6ae | ||
|
|
0e95d99868 | ||
|
|
d9f3b4c18a | ||
|
|
80ebbca804 | ||
|
|
959cd4a3ae | ||
|
|
084a8e23e7 | ||
|
|
f5dd46a729 | ||
|
|
121873f41c | ||
|
|
16d6348cec | ||
|
|
28fc4d8a7f | ||
|
|
5726289b18 | ||
|
|
e85dab8cfc | ||
|
|
deccff15cf | ||
|
|
a1bf54c417 | ||
|
|
8c56921375 | ||
|
|
bf8551015c | ||
|
|
3f030959be | ||
|
|
0b8b9760bc | ||
|
|
f1fe82646d | ||
|
|
b7e03142e9 | ||
|
|
af3dc018c7 | ||
|
|
79bd0d4efb | ||
|
|
a364a95cb5 | ||
|
|
5b0c064e68 | ||
|
|
6f01d5550d | ||
|
|
9000f82213 | ||
|
|
587f7f3e61 | ||
|
|
2f91ca3dfd | ||
|
|
93e9b68821 | ||
|
|
d63c9e6451 | ||
|
|
71ab3bf391 | ||
|
|
9c0ed32485 | ||
|
|
8d752e0bd8 | ||
|
|
310b9cb3fd | ||
|
|
fceefe6d4d | ||
|
|
ba4f8b0be4 | ||
|
|
a7b7327ce5 | ||
|
|
6ae8b91934 | ||
|
|
3cab115a80 | ||
|
|
9e088ca7ac | ||
|
|
6cc8399dca | ||
|
|
4956e8ab90 | ||
|
|
d5cbdce870 | ||
|
|
15b7ce4481 | ||
|
|
8df7f682e0 | ||
|
|
30619caf0f | ||
|
|
47e144ac1f | ||
|
|
5fada99e25 | ||
|
|
2e4b353fcd | ||
|
|
d0e5c105ec | ||
|
|
9a03097f80 | ||
|
|
228f2a67e4 | ||
|
|
9331bc43cb | ||
|
|
f98a55dca0 | ||
|
|
5139daef90 | ||
|
|
d3728f4108 | ||
|
|
b99450db2f | ||
|
|
a90ca2f039 | ||
|
|
ad4aa4aba4 | ||
|
|
c4cb44709d | ||
|
|
24e755a097 | ||
|
|
075f7f6d78 | ||
|
|
4124d6c5a4 | ||
|
|
64c48de1d1 | ||
|
|
012ba9cb97 | ||
|
|
6644a208bb | ||
|
|
54647413c4 | ||
|
|
93a50cf144 | ||
|
|
b7985306cd | ||
|
|
27e18f1c96 | ||
|
|
2d00be71bb | ||
|
|
6e3d45fddc | ||
|
|
fd3a3f1da6 | ||
|
|
9205f9d36e | ||
|
|
991169ba5a | ||
|
|
b513fa6f4e | ||
|
|
3384a05932 | ||
|
|
9051f6ca3e | ||
|
|
d9c6a4ea31 | ||
|
|
160690bad8 | ||
|
|
1aecb0a4d9 | ||
|
|
15b000d555 | ||
|
|
2d3b646e45 | ||
|
|
2bebe8f42d | ||
|
|
0792105b96 | ||
|
|
8ebc716de8 | ||
|
|
b09ebb5cd2 | ||
|
|
9437a74147 | ||
|
|
4206d50c70 | ||
|
|
0b4bd5680d | ||
|
|
6b591cdd88 | ||
|
|
7db57bba3b | ||
|
|
b907c4b05e | ||
|
|
2d376c2bed | ||
|
|
738595be5a | ||
|
|
7bc3e5d8a0 | ||
|
|
65a1f49f93 | ||
|
|
5b5eabf5db | ||
|
|
601f731135 | ||
|
|
3d25fcaae2 | ||
|
|
adf82f8f01 | ||
|
|
05ef746f22 | ||
|
|
9670d0a86a | ||
|
|
abefc42fdf | ||
|
|
ab36ba6f81 | ||
|
|
1d40d70150 | ||
|
|
485d2f2b04 | ||
|
|
40ec26ab7e | ||
|
|
c95d104245 | ||
|
|
1cf525844b | ||
|
|
ae2d3e1eab | ||
|
|
e8530ad3db | ||
|
|
e39ab7d530 | ||
|
|
738eac3ebb | ||
|
|
1479c777d0 | ||
|
|
6302c43e06 | ||
|
|
63e664efdd | ||
|
|
b708a08fd0 | ||
|
|
c9a35b730d | ||
|
|
d573cffe0e | ||
|
|
b59476f2f9 | ||
|
|
9b40ba0b7c | ||
|
|
6f82c15c69 | ||
|
|
1de6e91ba3 | ||
|
|
3f6e9b00f7 | ||
|
|
60383bee64 | ||
|
|
b0cf0deab0 | ||
|
|
bdde03cdfa | ||
|
|
6160090919 | ||
|
|
1ed013bde2 | ||
|
|
046c99e219 | ||
|
|
8821b05b7b | ||
|
|
c0c42d5f46 | ||
|
|
332b6fb4f3 | ||
|
|
a8d365c1ee | ||
|
|
6e21218f05 | ||
|
|
fb33e5cb16 | ||
|
|
52de302f2b | ||
|
|
f2162a663a | ||
|
|
1f2d298419 | ||
|
|
d1c052c9e8 | ||
|
|
79cac7d80f | ||
|
|
c356a65a5f | ||
|
|
ca171089c2 | ||
|
|
e153b42b2e | ||
|
|
20dc9069d7 | ||
|
|
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 | ||
|
|
73b479a408 | ||
|
|
16183285b5 | ||
|
|
3b3cc1eba0 | ||
|
|
3da5517996 | ||
|
|
cb26da3de5 | ||
|
|
23a0a47a7f | ||
|
|
aac6c09a04 | ||
|
|
bf90d6312b | ||
|
|
ec15c260e1 | ||
|
|
47ab3a9a76 | ||
|
|
e8bcf04b46 |
+67
-2
@@ -1,19 +1,49 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
SQLITE_PATH=./data/homelab.db
|
||||
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
|
||||
# which sits on the same persistent volume as the DB.
|
||||
# UPLOAD_DIR=./data/uploads
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Auth — default credentials: admin / admin
|
||||
# Auth — local mode is the backward-compatible default.
|
||||
AUTH_MODE=local
|
||||
# Local credentials: admin / admin by default.
|
||||
# ⚠️ Change before exposing on a network.
|
||||
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
|
||||
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
# OpenID Connect (optional alternative to local auth).
|
||||
# Set AUTH_MODE=oidc and configure every value below. The callback must be
|
||||
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
|
||||
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
|
||||
# also requires SECRET_KEY to contain at least 32 bytes.
|
||||
# AUTH_MODE=oidc
|
||||
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
# OIDC_CLIENT_ID=homelable
|
||||
# OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
# OIDC_COOKIE_SECURE=true
|
||||
# OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
|
||||
|
||||
# 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
|
||||
|
||||
@@ -34,3 +64,38 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# the same value in the `X-API-Key` header.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# HOMEPAGE_API_KEY=
|
||||
|
||||
# Proxmox VE import — pull hosts/VMs/LXC from the Proxmox REST API.
|
||||
# The token is a credential: kept in memory only, never written to disk by the
|
||||
# app, never returned by any API. Create it under Datacenter → Permissions →
|
||||
# API Tokens and grant the read-only PVEAuditor role at path "/".
|
||||
# Token id format: user@realm!tokenname (e.g. root@pam!homelable).
|
||||
# Required only for auto-sync; one-off imports can pass the token in the dialog.
|
||||
# PROXMOX_TOKEN_ID=root@pam!homelable
|
||||
# PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
# PROXMOX_HOST=192.168.1.10
|
||||
# PROXMOX_PORT=8006
|
||||
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
|
||||
|
||||
# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule.
|
||||
# MQTT credentials are secrets: kept in memory only, never written to disk by
|
||||
# the app, never returned by any API. Manual imports (the Zigbee dialog) are
|
||||
# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now.
|
||||
# Required only for auto-sync; one-off imports pass their config in the dialog.
|
||||
# ZIGBEE_MQTT_HOST=192.168.1.20
|
||||
# ZIGBEE_MQTT_PORT=1883
|
||||
# ZIGBEE_MQTT_USERNAME=mqttuser # optional
|
||||
# ZIGBEE_MQTT_PASSWORD=mqttpass # optional
|
||||
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
|
||||
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
|
||||
# ZWAVE_MQTT_HOST=192.168.1.20
|
||||
# ZWAVE_MQTT_PORT=1883
|
||||
# ZWAVE_MQTT_USERNAME=mqttuser # optional
|
||||
# ZWAVE_MQTT_PASSWORD=mqttpass # optional
|
||||
# ZWAVE_PREFIX=zwave
|
||||
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
|
||||
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke-and-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,3 +60,20 @@ jobs:
|
||||
- run: ruff check .
|
||||
- run: mypy app/
|
||||
- run: pytest
|
||||
|
||||
# Also guards the NODE_TYPES / EDGE_TYPES enums in mcp/app/tools.py against
|
||||
# drift from frontend/src/types/index.ts (test_node_types_sync.py,
|
||||
# test_edge_types_sync.py) — those tests need the frontend sources, so this
|
||||
# job runs from a full checkout rather than the mcp Docker context.
|
||||
mcp:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- run: pip install -r requirements.txt
|
||||
- run: pytest
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
schedule:
|
||||
- cron: '0 9 * * 1' # Weekly on Monday
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
secrets-scan:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -19,6 +22,12 @@ jobs:
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
# Lob (a direct-mail API this project does not use) matches any
|
||||
# `test_`-prefixed identifier of ~35 chars and reports it verified,
|
||||
# because Lob test keys authenticate unconditionally. Every pytest
|
||||
# function named at that length trips it — the first was
|
||||
# `test_updates_an_existing_device_in_place`.
|
||||
extra_args: --exclude-detectors=Lob
|
||||
|
||||
dependency-audit:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -28,11 +37,12 @@ jobs:
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: NPM Audit
|
||||
run: cd frontend && npm audit --audit-level=high
|
||||
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
|
||||
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
|
||||
# bundled or served, so its advisories must not fail release CI.
|
||||
run: cd frontend && npm audit --omit=dev --audit-level=high
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Pip audit
|
||||
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
|
||||
# python-jose forces ecdsa as a dependency with no fixed version available.
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt
|
||||
|
||||
+7
-1
@@ -1,6 +1,5 @@
|
||||
# Claude / project meta — never commit
|
||||
CLAUDE.md
|
||||
FEATURES.md
|
||||
.claude/
|
||||
_project_specs/
|
||||
|
||||
@@ -46,7 +45,14 @@ htmlcov/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db.back
|
||||
*.db.back-*
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
Ideas.md
|
||||
|
||||
# Local dev/test utilities (never commit)
|
||||
scripts/zwave-mock-gateway.py
|
||||
|
||||
# Docs (local only)
|
||||
docs/database-model.md
|
||||
|
||||
+726
@@ -0,0 +1,726 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to **Homelable** are documented here.
|
||||
|
||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [3.3.1] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Approving a device no longer fails after an upgrade from 3.2.0: a node the 3.3.0 inventory backfill could not link kept the old device columns on `nodes`, and their NOT NULL constraint rejected every new node. (#352)
|
||||
|
||||
## [3.3.0] - 2026-08-15
|
||||
|
||||
### Features
|
||||
|
||||
- The Device Inventory is now the source of truth for device data: a node reads and edits its facts from its inventory row, and the device sheet is grouped by what a reader is after. (#339)
|
||||
- Per-service host override, so a service can point at a host other than the node's own. (#338)
|
||||
- A zone now parents the nodes dropped inside it. (#343)
|
||||
- Node properties accept an empty value. (#345)
|
||||
- Nodes can be given 0 connection points on top and bottom. (#336)
|
||||
- KVM switch added as a device type. (#335)
|
||||
- The sidebar version row links to the public changelog.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Alignment guides show for nodes inside groups and containers. (#337)
|
||||
- The status check interval is applied to the running scheduler. Thanks @JaG-v2. (#319)
|
||||
- Bumped undici and ip-address to patch high severity advisories. (#342)
|
||||
|
||||
### Docs
|
||||
|
||||
- The pre-built GHCR images are documented in the install guide.
|
||||
|
||||
## [3.2.0] - 2026-08-10
|
||||
|
||||
### Features
|
||||
|
||||
- Rack canvas: a third design type, next to network and electrical, with racks, mounted devices and port-to-port patching. (#315)
|
||||
- Declarative SVG faceplates picked from a visual catalog, including desktop NAS enclosures in 2, 4 and 5 bays. (#315)
|
||||
- Rack gear is filed in the Device Inventory, and a mount can be picked from the rackable entries. (#315)
|
||||
- Cables carry a type, colour, label and properties, printed on the canvas at the midpoint of the run. (#315)
|
||||
- Mounted devices follow the status check of the logical node they match, and links can be imported from the logical canvas. (#315)
|
||||
- Getting Started walkthrough covers the rack canvas. (#315)
|
||||
- Show the logical view of a mounted device, and link a mount to any Device Inventory entry. (#323)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Guard rack rows against cross-design saves and half-applied faceplates. (#315)
|
||||
- A rack height edit no longer orphans its mounts; height and colour commit on blur, and a blanked field no longer shrinks the rack. (#315)
|
||||
- Import links is idempotent, and stays usable after a run that matched nothing. (#315)
|
||||
- Stop the device modal losing cables, status and inventory rows. (#315)
|
||||
- Name rack inventory entries the way the Device Inventory does. (#315)
|
||||
- Stamp the linked node's last-seen with an offset. (#323)
|
||||
- Bump js-yaml to 4.3.1 for CVE-2026-59870. (#315)
|
||||
- CI: stop the secret scan reporting pytest names as Lob keys. (#315)
|
||||
|
||||
### Docs
|
||||
|
||||
- Document the rack canvas for users. (#315)
|
||||
|
||||
## [3.1.2] - 2026-08-01
|
||||
|
||||
### Features
|
||||
|
||||
- Generic OIDC authentication: backend sessions and browser login flow. Thanks @autogpt-elendir. (#306)
|
||||
- Brand icon picker for services, in a dedicated add/edit modal. (#313)
|
||||
- MCP: lightweight node lookup tools. Thanks @Nebesz. (#312)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sync the MCP node and edge type enums with the frontend, gated in CI against drift. Thanks @Nebesz. (#311)
|
||||
- Harden OIDC callback validation. (#306)
|
||||
- Use the official Homelable logo on the login page. (#306)
|
||||
- Clear the seven open dependabot alerts and refresh vulnerable frontend packages. (#317, #306)
|
||||
|
||||
### Docs
|
||||
|
||||
- Document OIDC setup and configuration. (#306)
|
||||
|
||||
## [3.1.1] - 2026-07-19
|
||||
|
||||
### Features
|
||||
|
||||
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
|
||||
- Getting Started walkthrough tour with first-run canvas detection. (#300)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Round-trip Show Port Numbers on YAML export/import. (#301)
|
||||
- Render error toasts on a red surface. (#300)
|
||||
|
||||
### Docs
|
||||
|
||||
- Rework README header with logo, badges, and nav links. (#300)
|
||||
|
||||
## [3.1.0] - 2026-07-18
|
||||
|
||||
### Features
|
||||
|
||||
- Drag to reorder node properties. (#296)
|
||||
- Search for nodes and properties. Thanks @Floyddotnet. (#294)
|
||||
- Widen zone modal into two columns. (#293)
|
||||
- Autosave canvas after a configurable inactivity delay (opt-in). Thanks @nicolabottini. (#289)
|
||||
- MCP: `design_id` on approve_device + `delete_design` tool. Thanks @nicolabottini. (#281)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Scanner: decouple port discovery from version detection. (#295, #277)
|
||||
- Allow undo after Auto Layout and YAML import. (#290)
|
||||
- Move edge waypoints when the connected node or its container is dragged. (#288)
|
||||
- Keep container host height when editing an unrelated field. (#287)
|
||||
- Keep container children in place when editing a host node. Thanks @nicolabottini. (#286)
|
||||
- `list_pending_devices` MCP tool leaked approved/hidden inventory rows. Thanks @MikeSviblov. (#273)
|
||||
|
||||
### Docs
|
||||
|
||||
- Fix MCP client transport to http (was sse). (#275)
|
||||
|
||||
## [3.0.0] - 2026-07-10
|
||||
|
||||
### Added
|
||||
|
||||
- Proxmox VE import: pull hosts, VMs and LXC into inventory, with optional scheduled auto-sync and a manual re-sync button (connection config is now env-only). (#253, #259)
|
||||
- Scheduled auto-sync for Zigbee & Z-Wave imports. (#270)
|
||||
- Floor plan map background, LQI-based edge coloring, and Zigbee path highlighting. Thanks @pranjal-joshi. (#207)
|
||||
- Customizable connection points per node side. (#249)
|
||||
- Configurable edge line style + width per type and per edge, plus selectable endpoint marker shapes. (#250, #252)
|
||||
- Create a new canvas by copying an existing one. (#257)
|
||||
- Prompt on duplicate device instead of silently blocking/merging on approve. (#261)
|
||||
- Active design synced to URL for refresh/share. (#263)
|
||||
- MCP can target a specific design/canvas, create canvases, and auto-position nodes with auto-assigned edge handles. Thanks @nicolabottini. (#266)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mesh (Zigbee/Z-Wave) import: duplicate-node crash and coordinator now routed to pending. (#247)
|
||||
- Keep mesh/cluster links so edges resolve onto a second canvas. (#254)
|
||||
- Preserve edge connection points in YAML export/import. (#255, #208)
|
||||
- Harden media path handling against path injection. (#256)
|
||||
- Match scanned devices to canvas nodes by ip-token and MAC. (#262, #258)
|
||||
- Record ScanRun for scheduled Proxmox auto-sync. (#269)
|
||||
|
||||
### Docs
|
||||
|
||||
- User-facing FEATURES.md. (#248)
|
||||
- CHANGELOG with full release history. (#264)
|
||||
|
||||
## [2.6.1] - 2026-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Z-Wave nodes can be added/edited manually. A Z-Wave group (Controller / Router / End Device) is now in the Add/Edit node type selector, matching Zigbee. Mesh-radio nodes default to no status check.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Standalone (frontend-only) multi-canvas (designs) support, originally merged in #244 after the 2.6.0 tag with no version bump.
|
||||
|
||||
## [2.6.0] - 2026-06-28
|
||||
|
||||
Device Pending is gone — replaced by **Device Inventory**: devices stay in an inventory, can be placed in multiple canvases, rescanned and updated. New **Z-Wave** section: scan, import and store Z-Wave devices like Zigbee.
|
||||
|
||||
### Added
|
||||
|
||||
- SVG export option in the canvas export modal, alongside PNG (#238).
|
||||
- Deep-scan HTTP probe + Device Inventory: scans probe HTTP services and surface a per-device inventory (#222, closes #195).
|
||||
- Z-Wave network scan via the MQTT bridge (#224).
|
||||
- Inventory timestamps on nodes — nodes show when their inventory was last collected (#233).
|
||||
- New nodes drop at the centre of the visible canvas instead of off-screen (#234).
|
||||
- Expanded MAC OUI database: router, switch, AP, NAS and camera vendor prefixes (#220).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Optional white background on export and working downloads in Firefox (#239, closes #165).
|
||||
- MCP-created nodes/edges attach to the active design instead of floating (#237, closes #225).
|
||||
- Stopping a scan interrupts the running nmap range immediately (#236, closes #218).
|
||||
- Auto-layout orders Proxmox children by parent port number (#235).
|
||||
- Copy-to-clipboard for Markdown works on non-secure HTTP origins (#219).
|
||||
|
||||
### Docs
|
||||
|
||||
- Documented the `SCANNER_HTTP_RANGES` port-spec format (#232).
|
||||
|
||||
## [2.5.1] - 2026-06-18
|
||||
|
||||
### Fixed
|
||||
|
||||
- Manually resized nodes — including VM/LXC nodes nested inside a Proxmox container — keep their size on reload instead of snapping back to content-fit.
|
||||
- Node detail panel has a Size section for pixel-exact width/height; fields resync live when dragging a corner handle.
|
||||
- Adding an LXC/VM under a non-container-mode Proxmox node no longer confines it to a tiny box — it stays a free, draggable node.
|
||||
|
||||
### Security
|
||||
|
||||
- Resolved high-severity npm audit advisories (`esbuild`, `form-data`, `vite` → 7.3.5).
|
||||
- Bumped `python-multipart` to 0.0.31 (CVE-2026-53538/53539/53540).
|
||||
|
||||
## [2.5.0] - 2026-06-11
|
||||
|
||||
### Added
|
||||
|
||||
- Scan History modal with run duration, finished timestamp, and kind/status filters (#203).
|
||||
- Container nesting: drag a node onto a container node to nest it; detach/re-parent via an editable container selector (#202).
|
||||
- Editable node groups: add/remove members and edit the group description directly (#200).
|
||||
- Multi-line edge labels (#199).
|
||||
- Per-service status checks with offline colouring (#198).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reduce status flapping, add IPv6 ping, colour manually-added web services (#198).
|
||||
- Keep non-HTTP services grey instead of red (#198).
|
||||
- Idempotent WebSocket connection removal — release the slot on any error (#198).
|
||||
- Persist group description with Ctrl+S, not only on blur (#200).
|
||||
|
||||
### Security
|
||||
|
||||
- Resolve Dependabot and code-scanning alerts; bump hono to 4.12.21+ (#197).
|
||||
- Bump zeroconf to 0.149.12 for CVE-2026-48045 (#202).
|
||||
|
||||
## [2.4.0] - 2026-06-05
|
||||
|
||||
### Added
|
||||
|
||||
- Multiple designs (canvases): build and switch between separate diagrams, plus a new electrical-device node type (#177).
|
||||
- Copy & paste nodes between designs (#189).
|
||||
- Switch port numbers (up to 64 ports) plus a new fibre-optic link style (#172).
|
||||
- Read-only Live View link in the header for sharing or wall displays (#185).
|
||||
- Scans keep discovered MAC addresses when a device is approved (#178).
|
||||
- Richer AI/MCP control: assistants can set the full set of node fields on create/update (#180).
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings moved into a dedicated window for a cleaner sidebar (#188).
|
||||
- "Hide IP" option now lives in Settings and is remembered between sessions (#189).
|
||||
- Smoother link animations for flowing/snake styles (#187).
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Show Port Numbers" now persists after reload (#191).
|
||||
- The Save button works again (previously only Ctrl/Cmd+S) (#190).
|
||||
- Zigbee re-import correctly brings back already-approved devices (#176).
|
||||
|
||||
### Security
|
||||
|
||||
- Dependency updates: zeroconf, qs, brace-expansion (#175, #182).
|
||||
|
||||
## [2.3.0] - 2026-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- Full node schema over MCP: `create_node` / `update_node` expose the complete backend schema (`os`, `notes`, `mac`, `services`, `cpu_count`, `cpu_model`, `ram_gb`, `disk_gb`, `properties`, …); `get_canvas` round-trips the same fields (#174).
|
||||
- MAC carried onto approved nodes from a scan (#168).
|
||||
- Richer switch & edge modeling: switch port cap raised to 64, port numbers shown, new fibre edge type.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Canvas includes the MAC property on the approved node so it shows immediately after approval (#168).
|
||||
- Zigbee: revive orphaned approved devices on re-import instead of dropping them (#167).
|
||||
|
||||
### Security
|
||||
|
||||
- Bump `zeroconf` 0.131.0 → 0.149.7 for CVE fixes.
|
||||
|
||||
## [2.2.0] - 2026-05-29
|
||||
|
||||
### Added
|
||||
|
||||
- Laptop & mobile node types with theme-coherent styling (#166).
|
||||
- Collapsible / expandable zones — `collapsed` is now a first-class `NodeData` field with edge rewiring and read-only liveview support (#158).
|
||||
- LXC / bare-metal MCP install script `lxc-mcp-install.sh` with env-var overrides and repo-clone fallback; MCP image published to GHCR (#163).
|
||||
|
||||
## [2.1.1] - 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- LiveView: nest Docker container children under their host node; apply saved theme & custom style on load.
|
||||
|
||||
## [2.1.0] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
- Draggable edge endpoints — reconnect either end of an edge to another node/handle; Proxmox containers gain snap points (#150).
|
||||
- Docker containers can live inside VMs, Proxmox hosts or LXCs; parent auto-cleaned on type change (#153, #154).
|
||||
- Group node side handles (top/right/bottom/left) for proper edge attachment (#152).
|
||||
- Multiple IPs per node — paste several IPs separated by comma/space/newline, each clickable individually (#136).
|
||||
- Zigbee device properties (IEEE, Vendor, Model, LQI) auto-populated on approve / re-import (#148).
|
||||
- Homepage widget stats endpoint: new `/stats` API for gethomepage integration (closes #131, #149).
|
||||
- Zone modal polish: centered opacity thumb, fixed font casing, full keyboard + ARIA support (#106).
|
||||
|
||||
### Security
|
||||
|
||||
- Dropped `passlib` in favour of direct `bcrypt`; status-check targets reject CLI-flag injection.
|
||||
|
||||
## [2.0.3] - 2026-05-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove check method for Zigbee nodes.
|
||||
|
||||
## [2.0.2] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- Activated dashboardicons icons — brand-new icons selectable for nodes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Various modal fixes.
|
||||
|
||||
## [2.0.1] - 2026-05-11
|
||||
|
||||
### Added
|
||||
|
||||
- New Zigbee nodes available in the new/edit node modal.
|
||||
|
||||
## [2.0.0] - 2026-05-11
|
||||
|
||||
A major milestone: Zigbee2MQTT integration, a new pending-devices modal, text nodes and alignment guides.
|
||||
|
||||
### Added
|
||||
|
||||
- **Zigbee2MQTT integration** (thanks @pranjal-joshi): import a full Zigbee network map from Z2M as a background scan run; three new node types (Coordinator, Router, End Device); MQTT TLS with optional cert-verify skip; imported devices flow through the pending section with edge persistence.
|
||||
- **New pending devices modal**: full-screen grid with cards, filters (IP / Zigbee / all), search, multi-select and bulk restore.
|
||||
- **Text node type** for free-form annotations (#138).
|
||||
- **Alignment guides + snap** while dragging nodes, built on `OnNodeDrag` (#139).
|
||||
- **Per-node services toggle** to show/hide the services list on the node (thanks @findthelorax) (#107).
|
||||
- Zigbee node types exposed in the Custom Style editor.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Status checker: ms timeout for ping on macOS.
|
||||
- Scan: default `check_method='ping'` for approved devices.
|
||||
- Detail panel: handle non-Z timezone offsets in Last Seen.
|
||||
- Groups: preserve children + size on edit; fix status WebSocket proxy.
|
||||
- Pending: drop dangling `onNodeApproved` call; null-safe pending IP in SearchModal.
|
||||
- Text node: persist text in `label` across reloads.
|
||||
|
||||
### Security
|
||||
|
||||
- Bump `fast-uri` (GHSA-q3j6-qgpj-74h6, GHSA-v39h-62p7-jpjc), `axios` ^1.15.2, `python-multipart`.
|
||||
- Sanitize MQTT error messages to prevent credential leakage.
|
||||
|
||||
## [1.13.0] - 2026-05-03
|
||||
|
||||
### Added
|
||||
|
||||
- New **Firewall** node type under Add Node → Hardware, with distinct flame icon and red accent.
|
||||
- Up to **48 bottom connection points** per node (slider, previously capped at 4); the node card grows wider as handles are added.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Sidebar no longer freezes on Scan History after starting a scan.
|
||||
- Delete confirmation respects Cancel — clicking Cancel keeps the modal open.
|
||||
|
||||
## [1.12.0] - 2026-04-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Custom Style Editor**: per-node-type border/background/icon colour + opacity, per-edge colour/opacity/path/animation, default size per type, "Apply to existing nodes" and "Apply All to Canvas"; saved with the canvas.
|
||||
|
||||
### Changed
|
||||
|
||||
- Accessibility overhaul: consistent hover/focus borders, pointer cursors, keyboard navigation and Enter-to-apply across all modals (#108).
|
||||
- IP address hint moved below the field; hover border colour toned down.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Edge labels and the `+` waypoint handle stay on the routed path when waypoints are used (#94).
|
||||
- Edge type select displays its full label (e.g. "IoT / Zigbee") instead of the raw value.
|
||||
- Status dot overlapping IP address in node cards.
|
||||
- Canvas style modal layout on smaller screens.
|
||||
- MCP session manager routing.
|
||||
|
||||
## [1.11.0] - 2026-04-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Docker Host** node type with container mode support (visual group for containers).
|
||||
- **Docker Container** node type, nestable inside a Docker Host.
|
||||
- Container mode now works for `docker_host`, `vm` and `lxc` (was Proxmox-only).
|
||||
|
||||
### Changed
|
||||
|
||||
- Service badges prioritise the service name; path truncates gracefully with a hover tooltip.
|
||||
- Node resizer handle hit area enlarged (8px → 16px).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Container mode not persisting after save/reload for non-Proxmox types.
|
||||
- Container mode toggle having no visual effect on `docker_host` nodes.
|
||||
- Status dot overlapping node content; consistent top-right placement.
|
||||
- Missing icon/label gap in ProxmoxGroupNode container header.
|
||||
- IP shown unmasked in ProxmoxGroupNode when hide-IP is enabled.
|
||||
|
||||
## [1.10.2] - 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Logout button** at the bottom of the sidebar (normal mode only).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pending Devices checkbox click no longer opens the approval modal.
|
||||
- NaN sent to the API when the status-check interval input is cleared.
|
||||
- Potential open-redirect in the update badge (release URL scheme now validated).
|
||||
|
||||
## [1.10.1] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- PNG export quality selector: Standard (1×), High (2×, default), Ultra (4×) (#89).
|
||||
- Zone colour opacity sliders for text/border/background (#72).
|
||||
- Optional service paths (e.g. `/admin`) appended to the clickable URL; port optional when a path is set (thanks @findthelorax) (#86).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bulk-approved nodes appear on canvas immediately (node IDs were null before DB flush).
|
||||
- Proxmox container mode: visible properties now shown; custom icon now applied.
|
||||
- `approve_device` returns 404 on missing device, 409 on double-approve (was 200).
|
||||
- Node form no longer retains previous-session values when reopened in Add mode (#87).
|
||||
|
||||
## [1.10.0] - 2026-04-19
|
||||
|
||||
### Added
|
||||
|
||||
- Bulk approve/hide pending devices (#70).
|
||||
- IPv6 support & multiple comma-separated IPs per node (#60).
|
||||
- Clickable IP addresses in the detail panel (#78).
|
||||
- Connection handles on zone/group rect nodes (#58).
|
||||
- Automatic DB backup before schema migrations.
|
||||
- Drag group from its title (#76); double-click a node to open its edit modal (#65).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Node width no longer expands when long content overflows after a user resize.
|
||||
- Proxmox nodes with `container_mode=false` restore their saved width on reload.
|
||||
- Added curl to the backend image for the default healthcheck.
|
||||
|
||||
## [1.9.0] - 2026-04-09
|
||||
|
||||
### Added
|
||||
|
||||
- **Node properties system**: dynamic key/value/icon/visible properties replacing static hardware fields; visible ones shown on the node card; 20 Lucide icons; existing hardware data auto-migrated.
|
||||
- **Edge waypoints**: click `+` to add, drag to move, double-click to remove; Bezier and Smooth step supported; persisted to backend.
|
||||
- **Basic edge animation** mode (native moving-dash), alongside Snake and Flow.
|
||||
- App version shown in sidebar with a GitHub release update check.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Node height no longer overflows when properties are added to a resized node.
|
||||
- Dot grid alignment with the snap grid.
|
||||
- Node selection layout shift.
|
||||
|
||||
## [1.8.3] - 2026-04-06
|
||||
|
||||
### Added
|
||||
|
||||
- Finer canvas grid: snap reduced from 16px to 8px.
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated frontend npm dependencies; upgraded lucide-react; removed unused Proxmox/LXC install scripts.
|
||||
|
||||
## [1.8.2] - 2026-04-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scan no longer starts before confirmation — "Scan Network" opens the config modal first.
|
||||
- Windows ping compatibility (`-n 1 -w 1000` instead of Linux/macOS flags).
|
||||
|
||||
## [1.8.1] - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Search now includes pending devices in both the canvas search bar (Ctrl+F) and command palette (Ctrl+K).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Timestamps (scan history, discovery time, Last Seen) now show local time instead of UTC.
|
||||
|
||||
## [1.8.0] - 2026-04-04
|
||||
|
||||
### Added
|
||||
|
||||
- Configurable 1–4 bottom connection points per node.
|
||||
- Fit view on load.
|
||||
- Inline Type and Icon pickers in the node modal.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Scanner rewrite**: Phase 1 concurrent asyncio ping sweep (50 parallel pings) supplemented by `/proc/net/arp`; Phase 2 explicit `-sS`/`-sT` scan type with 60s host-timeout; resilient gather so one failing host no longer aborts the batch.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Root logger StreamHandler so `app.*` logs are visible in Docker.
|
||||
- CIDR validation on frontend and backend to prevent nmap argument injection.
|
||||
- Pre-fetch canvas/hidden IPs before the scan loop (no N+1 queries); ARP table read off the event loop.
|
||||
- Hide/ignore on a missing device returns 404 instead of 500.
|
||||
|
||||
## [1.7.1] - 2026-04-02
|
||||
|
||||
### Added
|
||||
|
||||
- Concurrent status checks via `asyncio.gather`, ending "maximum instances reached" spam.
|
||||
- Improved IoT detection: two-phase nmap scan finds Shelly, Sonoff, Tapo devices with no open TCP ports.
|
||||
- mDNS/Bonjour discovery (`_shelly._tcp`, `_esphomelib._tcp`, `_hap._tcp`, `_mqtt._tcp`, …) in parallel with nmap.
|
||||
- 20+ IoT vendor MAC OUIs and CoAP ports added; IoT ranked above generic server.
|
||||
|
||||
### Fixed
|
||||
|
||||
- LXC/VM container mode: attaching to a Proxmox no longer creates a spurious edge; nesting is instant.
|
||||
- Scheduler reliability: DetachedInstanceError, duplicate timestamps, shutdown handling, interval validation.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Added `zeroconf==0.131.0` for mDNS discovery.
|
||||
|
||||
## [1.7.0] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- **Lasso / box selection** — draw a rectangle to select multiple nodes; hold Space to pan; lasso/pan toggle in the controls.
|
||||
- **Named groups** — group 2+ nodes into a resizable, renamable container with hide-border option and per-member navigation; persisted across save/reload.
|
||||
- **Canvas search (Ctrl+F)** — filter nodes by label, IP, hostname, service; live match count; click to fly the camera.
|
||||
|
||||
### Fixed
|
||||
|
||||
- LXC install script: `apt-get update` runs verbosely with `--fix-missing` to handle stale Debian mirrors (fixes #36).
|
||||
- Group parent/child relationships restore after save + reload.
|
||||
- Removed React Flow's default grey background from container node types.
|
||||
|
||||
## [1.6.0] - 2026-03-30
|
||||
|
||||
### Added
|
||||
|
||||
- Scan deduplication — pending devices already on canvas or hidden are skipped on rescan; stale entries purged at scan start.
|
||||
- Dedicated Settings panel (status-check interval moved out of the Scan Config modal).
|
||||
- Live interval update without a server restart.
|
||||
- Separate `/api/v1/settings` endpoint for the check interval.
|
||||
|
||||
### Fixed
|
||||
|
||||
- DEL key deletes selected nodes (previously Backspace only).
|
||||
- Node deletion via shortcut/detail panel is undoable with Ctrl+Z.
|
||||
- APScheduler guards against double-start and unguarded reschedule calls.
|
||||
|
||||
## [1.5.0] - 2026-03-29
|
||||
|
||||
### Added
|
||||
|
||||
- Inline service editing in the detail panel (pencil icon).
|
||||
- Edge animation modes: None, Snake, Flow — fully persisted.
|
||||
- Zone improvements: rename Rectangle → Zone, border-width selector, label position, text-size options.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Edge animation not saved on new connections.
|
||||
- Backend 422 when saving canvas with string animation values.
|
||||
- Snake and Flow animations rendering identically in production.
|
||||
- Login returned 500 instead of 401 when a bcrypt hash had `$` stripped by the shell (#21).
|
||||
- Hardcoded CORS in docker-compose; clearer login error messages.
|
||||
|
||||
## [1.4.0] - 2026-03-28
|
||||
|
||||
### Added
|
||||
|
||||
- **Live View** — share a read-only view of the canvas on your network, no login required.
|
||||
- Resizable nodes with persisted width/height.
|
||||
|
||||
### Fixed
|
||||
|
||||
- QEMU arm64 crash during Docker image build (`npm ci` illegal instruction on ARM64).
|
||||
- nginx `reload-or-start` fallback broken on fresh LXC installs.
|
||||
|
||||
### CI
|
||||
|
||||
- ShellCheck + hadolint linting; Docker smoke tests and full-stack integration tests.
|
||||
|
||||
## [1.3.3] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Scan failures on fresh Docker installs — `service_signatures.json` moved into the app package so the volume mount no longer overwrites it.
|
||||
- Missing `ping` on Docker (`iputils-ping` added).
|
||||
- Thread-safe signature-file loading; clear error if the file is missing.
|
||||
- CORS restricted to the HTTP methods/headers the frontend uses.
|
||||
|
||||
## [1.3.2] - 2026-03-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ping check method on fresh Docker installs — `iputils-ping` added to the backend image (`python:3.13-slim` ships without `ping`).
|
||||
|
||||
## [1.3.1] - 2026-03-27
|
||||
|
||||
### Added
|
||||
|
||||
- **YAML import/export** of the full canvas topology (nodes, edges, hardware specs, connections); import merges without overwriting; Dagre auto-layout on import; toolbar Import/Export buttons.
|
||||
|
||||
### Fixed
|
||||
|
||||
- ESLint 10 incompatibility from `npm audit fix` — pinned back to ESLint 9.x.
|
||||
|
||||
### Docs
|
||||
|
||||
- Split installation into `INSTALLATION.md`; documented the network scanner and dev mode.
|
||||
|
||||
## [1.3.0] - 2026-03-21
|
||||
|
||||
### Added
|
||||
|
||||
- Hardware specs per node (CPU model, cores, RAM, Disk) with "Show on node" and GB → TB formatting.
|
||||
- `Docker Host` node type (Anchor icon), themed across all 5 themes.
|
||||
- Group rectangle border style: Solid, Dashed, Dotted, Double, None.
|
||||
- Categorized node-type selector (Hardware / Virtualization / IoT / Generic).
|
||||
|
||||
## [1.2.2] - 2026-03-17
|
||||
|
||||
### Added
|
||||
|
||||
- `scripts/update.sh` for fast in-place LXC updates (never touches `.env` or DB).
|
||||
|
||||
### Fixed
|
||||
|
||||
- `crypto.randomUUID` crash on HTTP/LXC installs — polyfill fallback.
|
||||
- WebSocket failure on LXC (hardcoded port 8000 bypassing Nginx); added `/api/v1/status/ws/` upgrade block.
|
||||
- Production build crash on LXC (test files in `tsconfig.app.json`).
|
||||
|
||||
### Security
|
||||
|
||||
- JWT no longer exposed in the WebSocket URL — sent as the first message after connect.
|
||||
|
||||
## [1.2.1] - 2026-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- **MCP server** with HTTP/SSE transport for AI integration (Claude Code compatible); `parent_id` exposed in `update_node`; service-key auth for MCP → backend.
|
||||
|
||||
### Fixed
|
||||
|
||||
- "Add node" appeared broken with an empty Label — native validation doesn't render in Radix Dialog portals; now shows an inline error.
|
||||
- SSE streaming crash; reduced `get_canvas` token usage.
|
||||
|
||||
## [1.2.0] - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- Edge flow animation (per-edge toggle); Proxmox cluster edges animate bidirectionally.
|
||||
- Keyboard shortcuts: Undo/Redo, Ctrl+K search, copy/paste, Ctrl+S save, `?` reference.
|
||||
- Canvas history (50-entry undo/redo stack) with toolbar buttons.
|
||||
- Node search spotlight (Ctrl+K) with fuzzy match and fly-to.
|
||||
- Copy/paste nodes (Ctrl+C / Ctrl+V) with 50px offset and fresh IDs.
|
||||
- Markdown table export of the node inventory.
|
||||
- Clickable hostname in the detail panel.
|
||||
- Shortcuts reference modal.
|
||||
|
||||
## [1.1.1] - 2026-03-11
|
||||
|
||||
### Added
|
||||
|
||||
- New logo and favicon; page title updated to Homelable.
|
||||
- **Hide IPs** toggle in the sidebar — masks the last two octets.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auto-layout: peer nodes placed on the same row (no staircase); correct left-to-right ordering; child nodes always below their parent.
|
||||
|
||||
## [1.1.0] - 2026-03-10
|
||||
|
||||
### Added
|
||||
|
||||
- **Group rectangles** — decorative resizable zones with configurable label/font/position/colours and z-order; saved with the canvas.
|
||||
- Add/remove services manually in the detail panel.
|
||||
- All TCP services now clickable (HTTPS auto-detected for 443/8443; non-web ports excluded).
|
||||
|
||||
## [1.0.0] - 2026-03-09
|
||||
|
||||
First stable public release of **Homelable**, a self-hosted homelab visualization tool.
|
||||
|
||||
### Added
|
||||
|
||||
- **Canvas**: interactive React Flow diagram; 11 node types; 5 edge types; Proxmox nested nodes; Dagre auto-layout; zoom/pan; snap-to-grid; PNG export.
|
||||
- **Network discovery**: nmap scanner over CIDR ranges; pending-device queue (approve/hide/ignore); service fingerprinting; MAC OUI detection for QEMU/Proxmox/VMware.
|
||||
- **Status monitoring**: per-node ping/http/https/tcp/ssh/prometheus/health checks; live WebSocket updates; scheduled background checks.
|
||||
- **Auth & persistence**: single-user JWT auth (bcrypt in `.env`); SQLite canvas state with an explicit Save button.
|
||||
- **Standalone mode**: backend-free diagram editor with localStorage persistence.
|
||||
- **Install options**: Docker Compose, Proxmox LXC, manual Debian/Ubuntu script.
|
||||
|
||||
[2.6.1]: https://github.com/Pouzor/homelable/compare/v2.6.0...v2.6.1
|
||||
[2.6.0]: https://github.com/Pouzor/homelable/compare/v2.5.1...v2.6.0
|
||||
[2.5.1]: https://github.com/Pouzor/homelable/compare/v2.5.0...v2.5.1
|
||||
[2.5.0]: https://github.com/Pouzor/homelable/compare/v2.4.0...v2.5.0
|
||||
[2.4.0]: https://github.com/Pouzor/homelable/compare/v2.3.0...v2.4.0
|
||||
[2.3.0]: https://github.com/Pouzor/homelable/compare/v2.2.0...v2.3.0
|
||||
[2.2.0]: https://github.com/Pouzor/homelable/compare/v2.1.1...v2.2.0
|
||||
[2.1.1]: https://github.com/Pouzor/homelable/compare/v2.1.0...v2.1.1
|
||||
[2.1.0]: https://github.com/Pouzor/homelable/compare/v2.0.3...v2.1.0
|
||||
[2.0.3]: https://github.com/Pouzor/homelable/compare/v2.0.2...v2.0.3
|
||||
[2.0.2]: https://github.com/Pouzor/homelable/compare/v2.0.1...v2.0.2
|
||||
[2.0.1]: https://github.com/Pouzor/homelable/compare/v2.0.0...v2.0.1
|
||||
[2.0.0]: https://github.com/Pouzor/homelable/compare/v1.13.0...v2.0.0
|
||||
[1.13.0]: https://github.com/Pouzor/homelable/compare/v1.12.0...v1.13.0
|
||||
[1.12.0]: https://github.com/Pouzor/homelable/compare/v1.11.0...v1.12.0
|
||||
[1.11.0]: https://github.com/Pouzor/homelable/compare/v1.10.2...v1.11.0
|
||||
[1.10.2]: https://github.com/Pouzor/homelable/compare/v1.10.1...v1.10.2
|
||||
[1.10.1]: https://github.com/Pouzor/homelable/compare/v1.10.0...v1.10.1
|
||||
[1.10.0]: https://github.com/Pouzor/homelable/compare/v1.9.0...v1.10.0
|
||||
[1.9.0]: https://github.com/Pouzor/homelable/compare/v1.8.3...v1.9.0
|
||||
[1.8.3]: https://github.com/Pouzor/homelable/compare/v1.8.2...v1.8.3
|
||||
[1.8.2]: https://github.com/Pouzor/homelable/compare/v1.8.1...v1.8.2
|
||||
[1.8.1]: https://github.com/Pouzor/homelable/compare/v1.8.0...v1.8.1
|
||||
[1.8.0]: https://github.com/Pouzor/homelable/compare/v1.7.1...v1.8.0
|
||||
[1.7.1]: https://github.com/Pouzor/homelable/compare/v1.7.0...v1.7.1
|
||||
[1.7.0]: https://github.com/Pouzor/homelable/compare/v1.6.0...v1.7.0
|
||||
[1.6.0]: https://github.com/Pouzor/homelable/compare/v1.5.0...v1.6.0
|
||||
[1.5.0]: https://github.com/Pouzor/homelable/compare/v1.4.0...v1.5.0
|
||||
[1.4.0]: https://github.com/Pouzor/homelable/compare/v1.3.3...v1.4.0
|
||||
[1.3.3]: https://github.com/Pouzor/homelable/compare/v1.3.2...v1.3.3
|
||||
[1.3.2]: https://github.com/Pouzor/homelable/compare/v1.3.1...v1.3.2
|
||||
[1.3.1]: https://github.com/Pouzor/homelable/compare/v1.3.0...v1.3.1
|
||||
[1.3.0]: https://github.com/Pouzor/homelable/compare/v1.2.2...v1.3.0
|
||||
[1.2.2]: https://github.com/Pouzor/homelable/compare/v1.2.1...v1.2.2
|
||||
[1.2.1]: https://github.com/Pouzor/homelable/compare/v1.2.0...v1.2.1
|
||||
[1.2.0]: https://github.com/Pouzor/homelable/compare/v1.1.1...v1.2.0
|
||||
[1.1.1]: https://github.com/Pouzor/homelable/compare/v1.1.0...v1.1.1
|
||||
[1.1.0]: https://github.com/Pouzor/homelable/compare/v1.0.0...v1.1.0
|
||||
[1.0.0]: https://github.com/Pouzor/homelable/releases/tag/v1.0.0
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
# Homelable Features
|
||||
|
||||
Here's what Homelable can do. One line on what each feature is, then how to switch it on and use it.
|
||||
|
||||
> **Two modes.** Same UI, two ways to run it:
|
||||
> - **Full mode**, with the backend (Docker/LXC). Everything works.
|
||||
> - **Standalone mode** (`VITE_STANDALONE=true`), no backend, canvas lives in your browser's `localStorage`. Great for just drawing; anything that needs a server (scanning, imports, device inventory, floor-plan upload, live view) is hidden.
|
||||
>
|
||||
> Features marked 🔒 need Full mode.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Zones](#1-zones)
|
||||
2. [Groups & Nesting](#2-groups--nesting)
|
||||
3. [Text Annotations](#3-text-annotations)
|
||||
4. [Multiple Canvases](#4-multiple-canvases)
|
||||
5. [Rack Canvas](#5-rack-canvas)
|
||||
6. [Customize Style](#6-customize-style)
|
||||
7. [Floor Plan](#7-floor-plan-)
|
||||
8. [Network Scanner (IP import)](#8-network-scanner-ip-import-)
|
||||
9. [Zigbee Import](#9-zigbee-import-)
|
||||
10. [Z-Wave Import](#10-z-wave-import-)
|
||||
11. [Proxmox VE Import](#11-proxmox-ve-import-)
|
||||
12. [Device Inventory](#12-device-inventory-)
|
||||
13. [Live Status Monitoring](#13-live-status-monitoring-)
|
||||
14. [Export (PNG / SVG / YAML / Markdown)](#14-export)
|
||||
15. [Live View (read-only public canvas)](#15-live-view-)
|
||||
16. [Gethomepage Widget](#16-gethomepage-widget-)
|
||||
17. [MCP Server (AI integration)](#17-mcp-server-)
|
||||
18. [Settings & Shortcuts](#18-settings--shortcuts)
|
||||
19. [Authentication (Local / OpenID Connect)](#19-authentication-local--openid-connect-)
|
||||
|
||||
---
|
||||
|
||||
## 1. Zones
|
||||
|
||||
**What:** Labeled boxes to group devices by area: "Living room", "Rack 1", "DMZ", whatever makes sense to you.
|
||||
|
||||
**Use:**
|
||||
- Sidebar → **Add Zone**. Give it a title and a color, then drag it around and resize it.
|
||||
- Drop nodes onto a zone and Homelable asks if you want to add them to it.
|
||||
- Zones sit behind your nodes and move on their own. They're just there to keep things tidy.
|
||||
|
||||
---
|
||||
|
||||
## 2. Groups & Nesting
|
||||
|
||||
**What:** Some devices hold others, like a **Proxmox** host with its **VMs** and **LXCs** inside. Those show up as an expandable container.
|
||||
|
||||
**Use:**
|
||||
- Drag a `vm` or `lxc` onto a `proxmox` node, confirm **Add to container**, and it becomes a child.
|
||||
- Click the container header to fold it open or shut; it resizes itself around what's inside.
|
||||
- The Zigbee and Z-Wave imports build the same kind of hierarchy for you (coordinator → routers → end devices).
|
||||
|
||||
---
|
||||
|
||||
## 3. Text Annotations
|
||||
|
||||
**What:** Loose text labels for notes, section titles, or anything you want to call out on the canvas.
|
||||
|
||||
**Use:** Sidebar → **Add Text**, type, drop it where you want, style it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Multiple Canvases
|
||||
|
||||
**What:** More than one diagram in a single install, say "Network", "Home automation", "Rack layout", each with its own nodes, links, floor plan, and style.
|
||||
|
||||
**Use:**
|
||||
- The **canvas switcher** is at the top of the sidebar. Click to jump between canvases, or **New Canvas** to start a fresh one.
|
||||
- Hover a canvas to **Edit** it (name, icon, floor plan) or **Delete** it. You can't delete the last one.
|
||||
- Each canvas saves on its own, so hit **Save Canvas** after you change something.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rack Canvas
|
||||
|
||||
**What:** The physical side of the lab, as its own kind of canvas: racks, the gear mounted in them, and the patch cables between their ports. Where a diagram says what talks to what, a rack says what sits in which U, and which switch port the uplink lands on.
|
||||
|
||||
**Use:**
|
||||
- **Canvas switcher** → **New Canvas** → Kind **Rack**. A canvas cannot change kind afterwards.
|
||||
- **Add Rack** in the header drops a rack; double-click its frame for U height, 19"/10" width, numbering direction, colours and the enclosed/open look.
|
||||
- **+ Device** in the sidebar mounts something: an entry from your **Device Inventory** 🔒, a **new device** (which joins the inventory tagged *Rack devices*), or a rack-only **accessory** (blank, shelf, cable manager). Double-click a plate to edit it, and **Unmount** to take it out — the device itself stays in the inventory.
|
||||
- Gear occupies a U range and part of a 12-column width grid, so half- and third-width machines share a U. A drop snaps to the nearest free slot, an impossible one previews red, and growing a device relocates it to the nearest slot that fits.
|
||||
- The **Faceplate** field opens a visual catalog — servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks — drawn as vector artwork that scales with the rack and follows your theme.
|
||||
- **Patch**: drag from one port to another (or click both in turn) to cable them, across racks if you need to. Click a cable to select it, then **Delete** or **Unplug**. A header select controls whether cables show on hover, always, or not at all.
|
||||
- **Status**: pin a mount's colour, or set **Check device** 🔒 so it follows the status check already configured on the matching diagram node.
|
||||
- **Import links** 🔒 derives patches from the physical links already drawn on your diagrams. Run it again after racking more gear — pairs already cabled are left alone.
|
||||
- Saving is explicit — **Save Rack** — like any other canvas.
|
||||
|
||||
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
|
||||
|
||||
---
|
||||
|
||||
## 6. Customize Style
|
||||
|
||||
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
|
||||
|
||||
**Use:**
|
||||
- Toolbar → **Style**. Pick a preset: **Default**, **Dark**, **Light**, **Neon**, or **Matrix**.
|
||||
- Or pick **Custom** and hit its **Edit** button to set border/background colors per node type and link colors per edge type.
|
||||
- Theme and custom colors are saved **per canvas** on your next **Save Canvas**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Floor Plan 🔒
|
||||
|
||||
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
|
||||
|
||||
**Use:**
|
||||
- Open the **canvas switcher** → **Edit** the active canvas (or double-click the floor plan already on the canvas).
|
||||
- In the **Floor Plan** section, upload an image and set its size and lock state.
|
||||
- The image lives on the backend and is loaded by URL, never baked into the canvas, so your canvases stay light. *(See ADR-001; floor plans are Full mode only.)*
|
||||
|
||||
---
|
||||
|
||||
## 8. Network Scanner (IP import) 🔒
|
||||
|
||||
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
|
||||
|
||||
**Use:**
|
||||
1. Sidebar → **Scan Network**. Scan History opens and keeps refreshing until it's done.
|
||||
2. Set the CIDR ranges you want in `SCANNER_RANGES` (`.env`), or override them per scan in the dialog.
|
||||
3. Whatever it finds shows up in the **Device Inventory** (below) to approve, hide, or ignore.
|
||||
|
||||
**Deep scan (custom ports):** to catch services on odd ports, add them in `.env`:
|
||||
```env
|
||||
SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
||||
SCANNER_HTTP_PROBE_ENABLED=true
|
||||
SCANNER_HTTP_VERIFY_TLS=false
|
||||
```
|
||||
|
||||
**Root note:** SYN scans and OS detection need root. If a scan trips on permissions, run `scripts/run_scan.py` with `sudo`, or on Linux give nmap the `NET_RAW` capability. Full details in the [README](./README.md#network-scanner).
|
||||
|
||||
---
|
||||
|
||||
## 9. Zigbee Import 🔒
|
||||
|
||||
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
|
||||
|
||||
**Use:**
|
||||
1. Sidebar → **Zigbee Import**.
|
||||
2. Enter broker host/port (default `1883`), any credentials, and the base topic (default `zigbee2mqtt`).
|
||||
3. **Test Connection** → **Fetch Devices** → pick from the grouped list → **Add N to Canvas**.
|
||||
|
||||
Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. The hierarchy (coordinator → routers → end devices) and **LQI** are filled in automatically. More: [docs/zigbee-import.md](./docs/zigbee-import.md).
|
||||
|
||||
---
|
||||
|
||||
## 10. Z-Wave Import 🔒
|
||||
|
||||
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
|
||||
|
||||
**Use:**
|
||||
1. Sidebar → **Z-Wave Import**.
|
||||
2. Enter broker host/port, any credentials, the MQTT prefix (default `zwave`), and the gateway name (default `zwavejs2mqtt`).
|
||||
3. **Test Connection** → send them to **Pending** or straight to the **Canvas** → import → pick devices → **Add N to Canvas**.
|
||||
|
||||
Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy comes from each node's neighbor list (Z-Wave has no LQI). More: [docs/zwave-import.md](./docs/zwave-import.md).
|
||||
|
||||
---
|
||||
|
||||
## 11. Proxmox VE Import 🔒
|
||||
|
||||
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
|
||||
|
||||
**Use:**
|
||||
1. Create a read-only API token in Proxmox (Datacenter → Permissions → API Tokens, role `PVEAuditor`).
|
||||
2. Sidebar → **Proxmox Import**.
|
||||
3. Enter host, port (default `8006`), and the token (`user@realm!tokenid` + secret) — or leave blank to use the server token.
|
||||
4. **Test Connection** → send to **Pending** or the **Canvas** → import → pick devices → **Add N to Canvas**.
|
||||
|
||||
Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge. The token is env-only, never stored on disk, never returned by the API. Enable auto-sync from **Settings** once `PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET` are set. More: [docs/proxmox-import.md](./docs/proxmox-import.md).
|
||||
|
||||
---
|
||||
|
||||
## 12. Device Inventory 🔒
|
||||
|
||||
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
|
||||
|
||||
**Use:**
|
||||
- Sidebar → **Device Inventory**. Each entry shows IP, MAC, hostname, and any OS and services detected.
|
||||
- Per device: **Approve** to drop a typed node on the canvas, **Hide** to stash it (you can get it back), or **Ignore** to dismiss it.
|
||||
- **Hidden Devices** is the sidebar entry where you review and restore anything you've hidden.
|
||||
|
||||
---
|
||||
|
||||
## 13. Live Status Monitoring 🔒
|
||||
|
||||
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
|
||||
|
||||
**Use:**
|
||||
- Pick a **check method** per node when you add or edit it:
|
||||
|
||||
| Method | Checks |
|
||||
|--------|--------|
|
||||
| `ping` | ICMP reachability |
|
||||
| `http` | GET, OK if status < 500 |
|
||||
| `https` | GET with TLS verify |
|
||||
| `tcp` | TCP connect to `host:port` |
|
||||
| `ssh` | TCP connect to port 22 |
|
||||
| `prometheus` | GET `/metrics` |
|
||||
| `health` | GET `/health` |
|
||||
|
||||
- Checks run on a timer (`STATUS_CHECKER_INTERVAL`, 60s by default) and stream to the UI over WebSocket, no refresh. The sidebar footer keeps a running online/offline tally.
|
||||
|
||||
---
|
||||
|
||||
## 14. Export
|
||||
|
||||
**What:** Get your canvas out as a picture or as structured data.
|
||||
|
||||
**Use (toolbar):**
|
||||
- **PNG**, a snapshot of the canvas, quality of your choice. Works in standalone too.
|
||||
- **SVG**, vector export, keeps fonts, icons, and colors crisp. Same dialog as PNG.
|
||||
- **Export (YAML)**, the whole canvas (nodes + links) as YAML you can re-import.
|
||||
- **Markdown**, copies your device inventory as a Markdown table, handy for docs or a README.
|
||||
|
||||
---
|
||||
|
||||
## 15. Live View 🔒
|
||||
|
||||
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
|
||||
|
||||
**Use:**
|
||||
1. Add `LIVEVIEW_KEY=your-secret-key` to `.env`, then `docker compose restart backend`.
|
||||
2. Open `http://<your-homelab-ip>/view?key=your-secret-key`.
|
||||
|
||||
Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab.
|
||||
|
||||
---
|
||||
|
||||
## 16. Gethomepage Widget 🔒
|
||||
|
||||
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
|
||||
|
||||
**Use:**
|
||||
1. Add `HOMEPAGE_API_KEY=your-secret-key` to `.env`, restart the backend.
|
||||
2. `GET /api/v1/stats/summary` with header `X-API-Key: your-secret-key` returns node counts, online/offline, pending, zigbee, and last scan time.
|
||||
|
||||
Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-stats).
|
||||
|
||||
---
|
||||
|
||||
## 17. MCP Server 🔒
|
||||
|
||||
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
|
||||
|
||||
**Use:**
|
||||
1. Add the keys to `.env`:
|
||||
```env
|
||||
MCP_API_KEY=mcp_sk_changeme # AI client -> MCP server
|
||||
MCP_SERVICE_KEY=svc_changeme # MCP server -> backend (internal only)
|
||||
# generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
```
|
||||
2. `docker compose up -d mcp` (listens on `:8001`). No Docker? `sudo bash scripts/lxc-mcp-install.sh`.
|
||||
3. Point your client at `http://<your-homelab-ip>:8001/mcp` with header `X-API-Key: <your key>`.
|
||||
|
||||
The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and edges, kick off scans, and approve or hide devices. Keep port 8001 firewalled to your LAN. Full setup in the [README](./README.md#mcp-server-ai-integration-optional).
|
||||
|
||||
---
|
||||
|
||||
## 18. Settings & Shortcuts
|
||||
|
||||
**What:** App config and keyboard shortcuts.
|
||||
|
||||
**Use:**
|
||||
- Sidebar → **Settings** for app-level config.
|
||||
- **Search** to find nodes fast.
|
||||
- Open the **Shortcuts** modal for the full key list (Save `Ctrl/Cmd+S`, undo/redo, and the rest).
|
||||
|
||||
---
|
||||
|
||||
## 19. Authentication (Local / OpenID Connect) 🔒
|
||||
|
||||
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
|
||||
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
|
||||
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
|
||||
|
||||
**Use (OIDC):**
|
||||
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
|
||||
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
|
||||
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
|
||||
|
||||
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
|
||||
|
||||
> OIDC is Full-mode only — the no-backend standalone build has no login.
|
||||
|
||||
---
|
||||
|
||||
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
|
||||
+51
-2
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
||||
>
|
||||
Generate a new hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
|
||||
```
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
|
||||
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
||||
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
||||
```bash
|
||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
||||
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
|
||||
```
|
||||
|
||||
## Quick Start — Frontend only
|
||||
@@ -40,8 +40,33 @@ curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh |
|
||||
cd homelable && docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
## Pre-built Docker images
|
||||
|
||||
The quick starts above never build anything — `install.sh` writes `docker-compose.prebuilt.yml` (or `docker-compose.standalone.yml` with `--standalone`) as your `docker-compose.yml`, and both pull ready-made images. They are published to the GitHub Container Registry on every push to `main` and every `v*` tag, for `linux/amd64` and `linux/arm64`:
|
||||
|
||||
| Image | Contents |
|
||||
|---|---|
|
||||
| [`ghcr.io/pouzor/homelable-backend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-backend) | FastAPI API, scanner, status checker |
|
||||
| [`ghcr.io/pouzor/homelable-frontend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend) | React SPA behind nginx, proxying `/api` to the backend |
|
||||
| [`ghcr.io/pouzor/homelable-frontend-standalone`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend-standalone) | Same SPA built with `VITE_STANDALONE=true` — no backend, canvases in `localStorage` |
|
||||
| [`ghcr.io/pouzor/homelable-mcp`](https://github.com/Pouzor/homelable/pkgs/container/homelable-mcp) | MCP server exposing the canvas to AI clients |
|
||||
|
||||
Tags: `latest` (tip of `main`), plus `X.Y.Z` and `X.Y` for releases.
|
||||
|
||||
To wire it up by hand instead of using `install.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSLO https://raw.githubusercontent.com/Pouzor/homelable/main/docker-compose.prebuilt.yml
|
||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/.env.example -o .env
|
||||
docker compose -f docker-compose.prebuilt.yml up -d
|
||||
```
|
||||
|
||||
Update the same way: `docker compose -f docker-compose.prebuilt.yml pull && … up -d`.
|
||||
|
||||
## Build from source
|
||||
|
||||
`docker-compose.yml` at the repo root builds the images locally instead of pulling them — use it for development, or to run a patched tree.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Pouzor/homelable.git
|
||||
cd homelable
|
||||
@@ -69,6 +94,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
All configuration is done via `.env` (copied from `.env.example`):
|
||||
|
||||
```env
|
||||
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_in_production
|
||||
|
||||
# Auth — default: admin / admin
|
||||
@@ -82,6 +108,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
|
||||
STATUS_CHECKER_INTERVAL=60
|
||||
```
|
||||
|
||||
### OpenID Connect (optional)
|
||||
|
||||
OIDC is an exclusive alternative to the local password. Existing installs stay
|
||||
in `AUTH_MODE=local` unless explicitly changed.
|
||||
|
||||
```env
|
||||
AUTH_MODE=oidc
|
||||
CORS_ORIGINS=["https://homelable.example"]
|
||||
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
|
||||
OIDC_CLIENT_ID=homelable
|
||||
OIDC_CLIENT_SECRET=replace-with-a-secret
|
||||
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
|
||||
OIDC_SCOPES="openid profile email"
|
||||
OIDC_COOKIE_SECURE=true
|
||||
OIDC_SESSION_EXPIRE_MINUTES=480
|
||||
```
|
||||
|
||||
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
|
||||
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
|
||||
backend uses Authorization Code with PKCE and keeps provider tokens out of the
|
||||
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
|
||||
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
|
||||
|
||||
All settings are also editable in-app via the **Scan Network** button.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,35 @@
|
||||
# Homelable
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&label=build" alt="Build status" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
|
||||
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
<a href="#rack-canvas">Rack Canvas</a> ·
|
||||
<a href="#network-scanner">Network Scanner</a> ·
|
||||
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
|
||||
<a href="#proxmox-ve-import">Proxmox</a> ·
|
||||
<a href="#live-view-read-only-public-canvas">Live View</a> ·
|
||||
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
|
||||
</p>
|
||||
|
||||
## About
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,8 +39,7 @@ You can also select some pre-built design styles, or personalize each device in
|
||||
|
||||
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)
|
||||
|
||||
If you are running <img width="22" height="22" align="top" 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -17,17 +47,43 @@ If you are running <img width="35" height="35" align="middle" alt="New_Home_Ass
|
||||
|
||||
<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/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" />
|
||||
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
|
||||
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
|
||||
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
|
||||
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, **rack canvases** with port-to-port patching, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
|
||||
|
||||
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Rack Canvas
|
||||
|
||||
Next to the network diagram, Homelable draws the **physical** side of your lab: racks, the gear mounted in them, and the patch cables between their ports. It is a canvas kind of its own — create it from the canvas switcher, **New Canvas → Kind → Rack**.
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Add Rack** in the header drops a rack; double-click its frame to set U height, 19"/10" width, numbering direction and colours
|
||||
2. **+ Device** in the sidebar mounts something — an entry from your Device Inventory, a new device (which joins the inventory), or an accessory (blank, shelf, cable manager)
|
||||
3. Pick a **faceplate** from the visual catalog: servers, switches, routers, patch panels, UPS and PDUs, desktop NAS towers, shelves and blanks
|
||||
4. Click **Patch**, then drag from one port to another to cable them — across racks if you need to. Click a cable and press Delete to unplug it
|
||||
5. **Save Rack** when you're happy; nothing is written behind your back
|
||||
|
||||
Gear sits in a U range and part of a 12-column width grid, so half- and third-width machines share a U; a drop snaps to the nearest free slot. A mount can follow the status check of its matching diagram node, and **Import links** derives patches from the links already drawn on your diagrams.
|
||||
|
||||
> **Full documentation:** [docs/rack-canvas.md](./docs/rack-canvas.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -40,6 +96,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:
|
||||
@@ -109,6 +179,68 @@ LQI (Link Quality Indicator) is stored as a node property.
|
||||
|
||||
---
|
||||
|
||||
## Z-Wave Import
|
||||
|
||||
Homelable can also import your **Z-Wave** network from **Z-Wave JS UI** (formerly `zwavejs2mqtt`) over the same MQTT broker, dropping each node on the canvas as a typed node.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||
- **Z-Wave JS UI** connected to the broker with its MQTT gateway enabled and at least one node included
|
||||
|
||||
### Usage
|
||||
|
||||
1. Click **Z-Wave Import** in the left sidebar (below "Zigbee Import")
|
||||
2. Enter your broker host, port (default `1883`), optional credentials, MQTT prefix (default `zwave`), and gateway name (default `zwavejs2mqtt`)
|
||||
3. Click **Test Connection** to verify reachability
|
||||
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Devices**
|
||||
5. Select the devices you want from the grouped list (Controller / Router / End Device) and click **Add N to Canvas**
|
||||
|
||||
### Node Types
|
||||
|
||||
| Type | Z-Wave Role | Icon |
|
||||
|------|-------------|------|
|
||||
| `zwave_coordinator` | Controller | Network hub |
|
||||
| `zwave_router` | Routing (mains-powered) node | Radio |
|
||||
| `zwave_enddevice` | End Device (battery) | Antenna |
|
||||
|
||||
Hierarchy is set automatically: controller → routers → end devices (`parent_id`), derived from each node's neighbor list. Z-Wave has no LQI, so that property is omitted.
|
||||
|
||||
> **Full documentation:** [docs/zwave-import.md](./docs/zwave-import.md)
|
||||
|
||||
---
|
||||
|
||||
## Proxmox VE Import
|
||||
|
||||
Homelable can import your **Proxmox VE** inventory over the Proxmox REST API — hosts, VMs and LXC containers arrive as typed, named nodes with run state and hardware specs, and can auto-sync on a schedule. Guest IPs that were already found by a network scan are merged in place (no duplicates).
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A reachable **Proxmox VE** host (default API port `8006`)
|
||||
- A **Proxmox API token** with the read-only **`PVEAuditor`** role (Datacenter → Permissions → API Tokens)
|
||||
|
||||
### Usage
|
||||
|
||||
1. Click **Proxmox Import** in the left sidebar (below "Z-Wave Import")
|
||||
2. Enter the host, port (default `8006`), and API token (`user@realm!tokenid` + secret) — or leave the token blank to use the server-configured one
|
||||
3. Click **Test Connection** to verify reachability + token
|
||||
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Inventory**
|
||||
5. Select the devices from the grouped list (Hosts / Virtual Machines / LXC Containers) and click **Add N to Canvas**
|
||||
|
||||
### Node Types
|
||||
|
||||
| Type | Proxmox object | Icon |
|
||||
|------|----------------|------|
|
||||
| `proxmox` | Host / cluster member | Layers |
|
||||
| `vm` | QEMU virtual machine | Box |
|
||||
| `lxc` | LXC container | Container |
|
||||
|
||||
Each host is linked to its guests with a `virtual` edge. vCPU / RAM / disk are imported as node properties (hidden by default). Enable **auto-sync** from Settings once a server token is configured (`PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET`).
|
||||
|
||||
> **Full documentation:** [docs/proxmox-import.md](./docs/proxmox-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.
|
||||
@@ -233,7 +365,7 @@ docker compose up -d mcp
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
```bash
|
||||
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
|
||||
claude mcp add --transport http homelable http://<your-homelab-ip>:8001/mcp/ \
|
||||
--header "X-API-Key: mcp_sk_yourkey"
|
||||
```
|
||||
|
||||
@@ -242,8 +374,8 @@ Or add it manually to `~/.claude.json`:
|
||||
{
|
||||
"mcpServers": {
|
||||
"homelable": {
|
||||
"type": "sse",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||
"type": "http",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp/",
|
||||
"headers": {
|
||||
"X-API-Key": "mcp_sk_yourkey"
|
||||
}
|
||||
@@ -257,8 +389,8 @@ Or add it manually to `~/.claude.json`:
|
||||
{
|
||||
"mcpServers": {
|
||||
"homelable": {
|
||||
"type": "sse",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||
"type": "http",
|
||||
"url": "http://<your-homelab-ip>:8001/mcp/",
|
||||
"headers": {
|
||||
"X-API-Key": "mcp_sk_yourkey"
|
||||
}
|
||||
|
||||
+45
-10
@@ -1,31 +1,66 @@
|
||||
import hmac
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import decode_token
|
||||
from app.core.security import decode_token, get_oidc_session
|
||||
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
@dataclass(frozen=True)
|
||||
class AuthContext:
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def get_auth_context(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
|
||||
x_mcp_service_key: str | None = Header(default=None),
|
||||
) -> str:
|
||||
) -> AuthContext:
|
||||
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
|
||||
if x_mcp_service_key is not None:
|
||||
if not settings.mcp_service_key:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
|
||||
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
|
||||
return "__mcp_service__"
|
||||
return AuthContext(
|
||||
subject="__mcp_service__",
|
||||
display_name="MCP service",
|
||||
auth_method="mcp",
|
||||
)
|
||||
|
||||
# 2. Standard JWT bearer token
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return username
|
||||
if credentials is not None:
|
||||
username = decode_token(credentials.credentials)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||
return AuthContext(
|
||||
subject=username,
|
||||
display_name=username,
|
||||
auth_method="local",
|
||||
)
|
||||
|
||||
# 3. OIDC session cookie (only active in exclusive OIDC mode)
|
||||
if settings.auth_mode == "oidc":
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is not None:
|
||||
return AuthContext(
|
||||
subject=oidc_session.subject,
|
||||
display_name=oidc_session.display_name,
|
||||
auth_method="oidc",
|
||||
issuer=oidc_session.issuer,
|
||||
csrf_token=oidc_session.csrf_token,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
|
||||
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
|
||||
return context.subject
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import cast
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import JoseError
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from pydantic import BaseModel
|
||||
from starlette.responses import RedirectResponse
|
||||
|
||||
from app.api.deps import AuthContext, get_auth_context
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, verify_password
|
||||
from app.core.security import (
|
||||
clear_oidc_session_cookie,
|
||||
create_access_token,
|
||||
create_oidc_session_token,
|
||||
get_oidc_client,
|
||||
set_oidc_session_cookie,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
mode: str
|
||||
oidc_login_url: str | None = None
|
||||
|
||||
|
||||
class CurrentUserResponse(BaseModel):
|
||||
subject: str
|
||||
display_name: str
|
||||
auth_method: str
|
||||
issuer: str | None = None
|
||||
csrf_token: str | None = None
|
||||
|
||||
|
||||
def _require_oidc_mode() -> None:
|
||||
if settings.auth_mode != "oidc":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
|
||||
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config() -> AuthConfigResponse:
|
||||
return AuthConfigResponse(
|
||||
mode=settings.auth_mode,
|
||||
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(body: LoginRequest) -> TokenResponse:
|
||||
if settings.auth_mode != "local":
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
|
||||
# Always run both checks to prevent timing-based username enumeration.
|
||||
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
||||
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
||||
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
token = create_access_token(body.username)
|
||||
return TokenResponse(access_token=token)
|
||||
|
||||
|
||||
@router.get("/oidc/login")
|
||||
async def oidc_login(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
|
||||
|
||||
|
||||
@router.get("/oidc/callback")
|
||||
async def oidc_callback(request: Request) -> Response:
|
||||
_require_oidc_mode()
|
||||
client = get_oidc_client()
|
||||
try:
|
||||
token = await client.authorize_access_token(request)
|
||||
except (OAuthError, JoseError) as exc:
|
||||
logger.warning("OIDC callback rejected: %s", exc.error)
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
userinfo = token.get("userinfo")
|
||||
if not isinstance(userinfo, Mapping):
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
|
||||
try:
|
||||
session_token = create_oidc_session_token(userinfo)
|
||||
except ValueError:
|
||||
request.session.clear()
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
||||
|
||||
request.session.clear()
|
||||
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
set_oidc_session_cookie(response, session_token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=CurrentUserResponse)
|
||||
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
|
||||
return CurrentUserResponse(
|
||||
subject=context.subject,
|
||||
display_name=context.display_name,
|
||||
auth_method=context.auth_method,
|
||||
issuer=context.issuer,
|
||||
csrf_token=context.csrf_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
clear_oidc_session_cookie(response)
|
||||
return response
|
||||
|
||||
@@ -12,6 +12,13 @@ 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
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,15 +35,21 @@ async def load_canvas(
|
||||
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()
|
||||
nodes = list((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}
|
||||
# The device facts live on the inventory row now; hydrate them back into the
|
||||
# node so the wire shape the canvas consumes is unchanged.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
# A CanvasState row exists only after a save (or explicit design create),
|
||||
# so its presence marks an intentional canvas vs. a never-touched one.
|
||||
initialized=state is not None,
|
||||
)
|
||||
|
||||
|
||||
@@ -71,16 +84,41 @@ async def save_canvas(
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Upsert nodes
|
||||
# Upsert nodes, then route their device facts to the inventory row that owns
|
||||
# them — an edit made on this canvas is an edit to the device itself, and
|
||||
# every other canvas showing it follows.
|
||||
saved: list[tuple[Node, dict[str, Any], list[str] | None]] = []
|
||||
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
|
||||
facts = facts_from_payload(payload, label=node_data.label, node_type=node_data.type)
|
||||
columns = node_columns(payload)
|
||||
if db_node:
|
||||
for field, value in payload.items():
|
||||
for field, value in columns.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**payload))
|
||||
db_node = Node(**columns)
|
||||
db.add(db_node)
|
||||
saved.append((db_node, facts, node_data.changed_facts))
|
||||
|
||||
await db.flush()
|
||||
for node, facts, changed in saved:
|
||||
# The payload carries a full copy of each device, hydrated when this
|
||||
# canvas loaded. Routing all of it back would rewrite the row from a
|
||||
# stale snapshot — a save made for nothing but a moved node could revert
|
||||
# an edit meanwhile in the inventory modal. `changed_facts` (when the
|
||||
# client tracked it) plus the row diff narrow the write to this canvas'
|
||||
# actual edit.
|
||||
await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=True,
|
||||
replace_lists=True,
|
||||
only_changed=True,
|
||||
changed_fields=changed,
|
||||
)
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, 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
|
||||
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Node.type values that are canvas annotations rather than real devices. Kept in
|
||||
# sync with the frontend (Sidebar counts, canvasSerializer types).
|
||||
_GROUP_TYPE = "groupRect"
|
||||
_TEXT_TYPE = "text"
|
||||
|
||||
|
||||
@router.get("", response_model=list[DesignResponse])
|
||||
async def list_designs(
|
||||
@@ -16,7 +23,31 @@ async def list_designs(
|
||||
_: 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]
|
||||
# One grouped query for all designs → node/group/text counts per design.
|
||||
rows = (
|
||||
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
|
||||
).all()
|
||||
counts: dict[str, dict[str, int]] = {}
|
||||
for design_id, node_type, count in rows:
|
||||
if design_id is None:
|
||||
continue
|
||||
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
|
||||
if node_type == _GROUP_TYPE:
|
||||
bucket["group"] += count
|
||||
elif node_type == _TEXT_TYPE:
|
||||
bucket["text"] += count
|
||||
else:
|
||||
bucket["node"] += count
|
||||
|
||||
result = []
|
||||
for d in designs:
|
||||
resp = DesignResponse.model_validate(d)
|
||||
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
|
||||
resp.node_count = c["node"]
|
||||
resp.group_count = c["group"]
|
||||
resp.text_count = c["text"]
|
||||
result.append(resp)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=DesignResponse, status_code=201)
|
||||
@@ -35,6 +66,123 @@ async def create_design(
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
|
||||
async def copy_design(
|
||||
source_id: str,
|
||||
body: DesignCopy,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
|
||||
source = await db.get(Design, source_id)
|
||||
if not source:
|
||||
raise HTTPException(404, "Source design not found")
|
||||
|
||||
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
|
||||
db.add(new_design)
|
||||
await db.flush()
|
||||
|
||||
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
|
||||
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
|
||||
|
||||
# New id per source node so edges and parent links can be re-pointed at the copy.
|
||||
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
|
||||
|
||||
# Columns we set explicitly or let the DB default — never copy verbatim.
|
||||
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
|
||||
for n in src_nodes:
|
||||
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
|
||||
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
|
||||
await db.flush() # nodes must exist before we wire self-referential parent_id
|
||||
|
||||
# Second pass: re-point parent links inside the copy.
|
||||
for n in src_nodes:
|
||||
if n.parent_id and n.parent_id in id_map:
|
||||
child = await db.get(Node, id_map[n.id])
|
||||
if child:
|
||||
child.parent_id = id_map[n.parent_id]
|
||||
|
||||
edge_skip = {"id", "design_id", "source", "target", "created_at"}
|
||||
for e in src_edges:
|
||||
# Skip edges whose endpoints aren't part of this design (dangling FKs).
|
||||
if e.source not in id_map or e.target not in id_map:
|
||||
continue
|
||||
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
|
||||
db.add(
|
||||
Edge(
|
||||
id=str(uuid.uuid4()),
|
||||
design_id=new_design.id,
|
||||
source=id_map[e.source],
|
||||
target=id_map[e.target],
|
||||
**cols,
|
||||
)
|
||||
)
|
||||
|
||||
# Copy the rack canvas, if the source has one. Racks, mounted gear and patches
|
||||
# all get new ids; the inventory / node links are carried over untouched, since
|
||||
# a copy documents the same hardware.
|
||||
src_racks = (await db.execute(select(Rack).where(Rack.design_id == source_id))).scalars().all()
|
||||
rack_id_map = {r.id: str(uuid.uuid4()) for r in src_racks}
|
||||
rack_skip = {"id", "design_id", "created_at", "updated_at"}
|
||||
for r in src_racks:
|
||||
cols = {c.name: getattr(r, c.name) for c in Rack.__table__.columns if c.name not in rack_skip}
|
||||
db.add(Rack(id=rack_id_map[r.id], design_id=new_design.id, **cols))
|
||||
|
||||
src_rack_devices = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == source_id))
|
||||
).scalars().all()
|
||||
rack_device_id_map = {d.id: str(uuid.uuid4()) for d in src_rack_devices}
|
||||
rack_device_skip = {"id", "design_id", "rack_id", "created_at", "updated_at"}
|
||||
for d in src_rack_devices:
|
||||
if d.rack_id not in rack_id_map:
|
||||
continue
|
||||
cols = {
|
||||
c.name: getattr(d, c.name)
|
||||
for c in RackDevice.__table__.columns
|
||||
if c.name not in rack_device_skip
|
||||
}
|
||||
db.add(
|
||||
RackDevice(
|
||||
id=rack_device_id_map[d.id],
|
||||
design_id=new_design.id,
|
||||
rack_id=rack_id_map[d.rack_id],
|
||||
**cols,
|
||||
)
|
||||
)
|
||||
|
||||
src_cables = (await db.execute(select(RackCable).where(RackCable.design_id == source_id))).scalars().all()
|
||||
cable_skip = {"id", "design_id", "from_device_id", "to_device_id", "created_at"}
|
||||
for c_row in src_cables:
|
||||
if c_row.from_device_id not in rack_device_id_map or c_row.to_device_id not in rack_device_id_map:
|
||||
continue
|
||||
cols = {
|
||||
c.name: getattr(c_row, c.name) for c in RackCable.__table__.columns if c.name not in cable_skip
|
||||
}
|
||||
db.add(
|
||||
RackCable(
|
||||
id=str(uuid.uuid4()),
|
||||
design_id=new_design.id,
|
||||
from_device_id=rack_device_id_map[c_row.from_device_id],
|
||||
to_device_id=rack_device_id_map[c_row.to_device_id],
|
||||
**cols,
|
||||
)
|
||||
)
|
||||
|
||||
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
|
||||
src_state = await db.get(CanvasState, source_id)
|
||||
db.add(
|
||||
CanvasState(
|
||||
design_id=new_design.id,
|
||||
viewport=src_state.viewport if src_state else {},
|
||||
custom_style=src_state.custom_style if src_state else None,
|
||||
)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(new_design)
|
||||
return DesignResponse.model_validate(new_design)
|
||||
|
||||
|
||||
@router.put("/{design_id}", response_model=DesignResponse)
|
||||
async def update_design(
|
||||
design_id: str,
|
||||
@@ -67,10 +215,20 @@ async def delete_design(
|
||||
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
|
||||
# Delete associated canvas state, rack rows, edges, nodes. SQLite does not
|
||||
# always enforce the ON DELETE CASCADE, so unwind by hand — cables before
|
||||
# devices before racks.
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
for cable in (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all():
|
||||
await db.delete(cable)
|
||||
for rack_device in (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
|
||||
).scalars().all():
|
||||
await db.delete(rack_device)
|
||||
for rack in (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all():
|
||||
await db.delete(rack)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
|
||||
@@ -4,11 +4,56 @@ 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, Node
|
||||
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-handle helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _abs_y(db: AsyncSession, node_id: str) -> float | None:
|
||||
"""Resolve the approximate absolute canvas Y of a node.
|
||||
|
||||
Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so
|
||||
that children inside containers are compared correctly against top-level nodes.
|
||||
Returns None when the node is not found.
|
||||
"""
|
||||
node = await db.get(Node, node_id)
|
||||
if node is None:
|
||||
return None
|
||||
y = node.pos_y
|
||||
current = node
|
||||
for _ in range(8):
|
||||
if current.parent_id is None:
|
||||
break
|
||||
parent = await db.get(Node, current.parent_id)
|
||||
if parent is None:
|
||||
break
|
||||
y += parent.pos_y
|
||||
current = parent
|
||||
return y
|
||||
|
||||
|
||||
async def _auto_handles(
|
||||
db: AsyncSession, source_id: str, target_id: str
|
||||
) -> tuple[str, str]:
|
||||
"""Return (source_handle, target_handle) that reflect the upstream/downstream
|
||||
relationship between two nodes.
|
||||
|
||||
- Source above target (lower Y value) → downstream flow: exit bottom, enter top
|
||||
- Source below target → upstream flow: exit top, enter bottom
|
||||
- Equal or unknown → default to bottom/top-t (most common topology direction)
|
||||
"""
|
||||
src_y = await _abs_y(db, source_id)
|
||||
tgt_y = await _abs_y(db, target_id)
|
||||
|
||||
if src_y is None or tgt_y is None or src_y <= tgt_y:
|
||||
return "bottom", "top-t"
|
||||
return "top", "bottom-t"
|
||||
|
||||
|
||||
@router.get("", response_model=list[EdgeResponse])
|
||||
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
|
||||
@@ -18,7 +63,26 @@ 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
|
||||
|
||||
# Auto-assign source/target handles when the caller omits them.
|
||||
# Compares the canvas Y positions of both nodes so that the edge always exits
|
||||
# the upstream node's bottom and enters the downstream node's top (or vice versa
|
||||
# for reverse flows), matching the UI convention for top-to-bottom topologies.
|
||||
if data.get("source_handle") is None or data.get("target_handle") is None:
|
||||
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
|
||||
if data.get("source_handle") is None:
|
||||
data["source_handle"] = auto_src
|
||||
if data.get("target_handle") is None:
|
||||
data["target_handle"] = auto_tgt
|
||||
|
||||
edge = Edge(**data)
|
||||
db.add(edge)
|
||||
await db.commit()
|
||||
await db.refresh(edge)
|
||||
|
||||
@@ -2,19 +2,42 @@ 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, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.services.inventory_sync import hydrated_node, load_devices_for
|
||||
|
||||
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),
|
||||
@@ -37,13 +60,15 @@ async def liveview_canvas(
|
||||
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()
|
||||
nodes = list((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
|
||||
# Device facts come off the inventory row, same as the editable canvas.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Generic media upload/serve endpoint.
|
||||
|
||||
Images are stored on disk (see `Settings.media_dir`) with server-generated
|
||||
UUID filenames — never in the DB, and the client filename is never trusted.
|
||||
Upload/delete require auth; GET is public so plain <img> tags and the read-only
|
||||
live view can load images (filenames are unguessable).
|
||||
|
||||
Currently used by the floor-plan feature; kept deliberately generic so future
|
||||
raw-image uploads reuse the same endpoint.
|
||||
"""
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# content-type → extension. Also the allowlist of accepted uploads.
|
||||
ALLOWED_TYPES: dict[str, str] = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
|
||||
_MAGIC: dict[str, tuple[bytes, ...]] = {
|
||||
".png": (b"\x89PNG\r\n\x1a\n",),
|
||||
".jpg": (b"\xff\xd8\xff",),
|
||||
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
|
||||
}
|
||||
|
||||
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
# Only ever serve/delete files we created: 32 hex chars + known extension.
|
||||
_NAME_RE = re.compile(r"[0-9a-f]{32}\.(png|jpg|webp)")
|
||||
|
||||
|
||||
def _resolve_media_path(filename: str) -> Path:
|
||||
"""Return the existing media file named `filename`, or raise 404.
|
||||
|
||||
`filename` is validated against `_NAME_RE` (no separators, no `..`), then
|
||||
matched by name against the actual directory listing. The user string is
|
||||
only ever compared with `==` against trusted `iterdir()` entries — it never
|
||||
builds a path — so a crafted value cannot escape the media dir.
|
||||
"""
|
||||
if not _NAME_RE.fullmatch(filename):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
base = settings.media_dir()
|
||||
if base.is_dir():
|
||||
for entry in base.iterdir():
|
||||
if entry.name == filename and entry.is_file():
|
||||
return entry
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||
ext = ALLOWED_TYPES.get(file.content_type or "")
|
||||
if ext is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="Unsupported media type — PNG, JPEG, or WebP only",
|
||||
)
|
||||
# Read one byte past the cap so we can detect oversize without loading more.
|
||||
data = await file.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||
detail="File too large (max 10 MB)",
|
||||
)
|
||||
if not data:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
|
||||
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail="File content does not match its type",
|
||||
)
|
||||
|
||||
media_dir = settings.media_dir()
|
||||
media_dir.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(media_dir / name).write_bytes(data)
|
||||
return {"filename": name, "url": f"/api/v1/media/{name}"}
|
||||
|
||||
|
||||
@router.get("/{filename}")
|
||||
async def get_media(filename: str) -> FileResponse:
|
||||
# _resolve_media_path returns only an existing file, else raises 404.
|
||||
return FileResponse(_resolve_media_path(filename))
|
||||
|
||||
|
||||
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
|
||||
_resolve_media_path(filename).unlink()
|
||||
+134
-13
@@ -1,50 +1,171 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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 Node
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
facts_from_update,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
from app.services.node_dedupe import find_duplicate_node
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-positioning helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Canvas grid used when placing nodes without explicit coordinates.
|
||||
# Each slot is wide/tall enough that a normal node card fits without overlap.
|
||||
_SLOT_W = 200.0
|
||||
_SLOT_H = 100.0
|
||||
_MAX_COLS = 7
|
||||
|
||||
|
||||
async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[float, float]:
|
||||
"""Return (x, y) for a new root-level node that doesn't collide with existing ones.
|
||||
|
||||
Snaps existing root nodes to a virtual grid and returns the first unoccupied
|
||||
cell, scanning left-to-right then top-to-bottom.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Node.pos_x, Node.pos_y).where(
|
||||
Node.design_id == design_id,
|
||||
Node.parent_id.is_(None),
|
||||
)
|
||||
)
|
||||
positions = list(result.all())
|
||||
if not positions:
|
||||
return 0.0, 0.0
|
||||
|
||||
# Snap each existing node to its true grid cell (negatives kept as-is). The
|
||||
# search below only visits col >= 0 / row >= 0, so nodes parked in negative
|
||||
# space never falsely block — or falsely free — a positive slot.
|
||||
occupied: set[tuple[int, int]] = set()
|
||||
for (px, py) in positions:
|
||||
col = round(px / _SLOT_W)
|
||||
row = round(py / _SLOT_H)
|
||||
occupied.add((col, row))
|
||||
|
||||
for row in range(10_000):
|
||||
for col in range(_MAX_COLS):
|
||||
if (col, row) not in occupied:
|
||||
return col * _SLOT_W, row * _SLOT_H
|
||||
|
||||
return 0.0, 0.0 # unreachable in practice
|
||||
|
||||
|
||||
@router.get("", response_model=list[NodeResponse])
|
||||
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
||||
result = await db.execute(select(Node))
|
||||
return list(result.scalars().all())
|
||||
async def list_nodes(
|
||||
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[Any]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
nodes = list((await db.execute(query)).scalars().all())
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return [hydrated_node(n, devices.get(n.device_id or "")) for n in nodes]
|
||||
|
||||
|
||||
@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())
|
||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
|
||||
data = body.model_dump()
|
||||
# `force` bypasses the duplicate guard below; it is not a Node column.
|
||||
force = data.pop("force", False)
|
||||
# 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
|
||||
|
||||
# Reject a silent duplicate: a node with the same ip OR mac already on the
|
||||
# target design. Scripts/MCP clients get a clear 409 (with the existing id)
|
||||
# instead of a second card for the same host. Pass force=True to override.
|
||||
if not force:
|
||||
dup = await find_duplicate_node(db, data["design_id"], data.get("ip"), data.get("mac"))
|
||||
if dup is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup)
|
||||
|
||||
# Auto-position: when pos_x / pos_y are omitted (None), find a free canvas
|
||||
# slot so the new node doesn't land on top of an existing one.
|
||||
# Child nodes (parent_id set) use (0, 0) relative to their parent instead.
|
||||
if data["pos_x"] is None or data["pos_y"] is None:
|
||||
if data.get("parent_id") is None:
|
||||
auto_x, auto_y = await _find_free_position(db, data["design_id"])
|
||||
if data["pos_x"] is None:
|
||||
data["pos_x"] = auto_x
|
||||
if data["pos_y"] is None:
|
||||
data["pos_y"] = auto_y
|
||||
else:
|
||||
if data["pos_x"] is None:
|
||||
data["pos_x"] = 0.0
|
||||
if data["pos_y"] is None:
|
||||
data["pos_y"] = 0.0
|
||||
|
||||
node = Node(**node_columns(data))
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
# A new device node gets (or joins) its Device Inventory row — the canvas is
|
||||
# one more way to document hardware, not a parallel store.
|
||||
await link_facts(
|
||||
db, node, facts_from_payload(data, label=data["label"], node_type=data["type"])
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
async def _hydrate(db: AsyncSession, node: Node) -> dict[str, Any]:
|
||||
"""Node as the API reports it, with the device facts read off its row."""
|
||||
device = await db.get(InventoryDevice, node.device_id) if node.device_id else None
|
||||
return hydrated_node(node, device)
|
||||
|
||||
|
||||
@router.get("/{node_id}", response_model=NodeResponse)
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
@router.patch("/{node_id}", response_model=NodeResponse)
|
||||
async def update_node(
|
||||
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> Node:
|
||||
) -> Any:
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
sent = body.model_dump(exclude_unset=True)
|
||||
for field, value in node_columns(sent).items():
|
||||
setattr(node, field, value)
|
||||
# The user edited the device, not just its drawing: push the facts down.
|
||||
# Only what was sent counts — an omitted field must not clear the row, and
|
||||
# lists are replaced only when the client actually sent them.
|
||||
facts = facts_from_update(sent)
|
||||
await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=True,
|
||||
replace_lists="properties" in sent or "services" in sent,
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(node)
|
||||
return node
|
||||
return await _hydrate(db, node)
|
||||
|
||||
|
||||
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
"""FastAPI router for Proxmox VE import + auto-sync config.
|
||||
|
||||
Fetches hosts/VMs/LXC from the Proxmox REST API and upserts them into the
|
||||
pending inventory (same review→approve flow as scans and mesh imports).
|
||||
|
||||
Credentials: the API token comes from the request body when provided, else
|
||||
falls back to the server-configured env token (``settings.proxmox_token_*``).
|
||||
The token is never persisted by the app and never returned by any endpoint.
|
||||
"""
|
||||
|
||||
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 or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.proxmox import (
|
||||
ProxmoxConfig,
|
||||
ProxmoxConnectionRequest,
|
||||
ProxmoxEdgeOut,
|
||||
ProxmoxImportPendingResponse,
|
||||
ProxmoxImportResponse,
|
||||
ProxmoxNodeOut,
|
||||
ProxmoxSyncConfig,
|
||||
ProxmoxTestConnectionResponse,
|
||||
)
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.proxmox_service import (
|
||||
build_proxmox_cluster_links,
|
||||
build_proxmox_properties,
|
||||
fetch_proxmox_inventory,
|
||||
merge_proxmox_properties,
|
||||
test_proxmox_connection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
# Discovery sources for the two proxmox link shapes. Host→guest links render as
|
||||
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
|
||||
_PROXMOX_GUEST_SOURCE = "proxmox"
|
||||
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"
|
||||
|
||||
|
||||
def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
|
||||
"""Pick the API token: request body first, else server env config.
|
||||
|
||||
Raises HTTP 400 when neither carries a token.
|
||||
"""
|
||||
token_id = payload.token_id or settings.proxmox_token_id
|
||||
token_secret = payload.token_secret or settings.proxmox_token_secret
|
||||
if not token_id or not token_secret:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No Proxmox API token provided and none configured on the server.",
|
||||
)
|
||||
return token_id, token_secret
|
||||
|
||||
|
||||
@router.post("/test-connection", response_model=ProxmoxTestConnectionResponse)
|
||||
async def test_connection_endpoint(
|
||||
payload: ProxmoxConnectionRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ProxmoxTestConnectionResponse:
|
||||
"""Validate host reachability + token before importing."""
|
||||
token_id, token_secret = _resolve_credentials(payload)
|
||||
connected, message = await test_proxmox_connection(
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
token_id=token_id,
|
||||
token_secret=token_secret,
|
||||
verify_tls=payload.verify_tls,
|
||||
)
|
||||
return ProxmoxTestConnectionResponse(connected=connected, message=message)
|
||||
|
||||
|
||||
@router.post("/import", response_model=ProxmoxImportResponse)
|
||||
async def import_proxmox(
|
||||
payload: ProxmoxConnectionRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ProxmoxImportResponse:
|
||||
"""Fetch the inventory and return nodes + edges ready for canvas drop."""
|
||||
token_id, token_secret = _resolve_credentials(payload)
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
||||
host=payload.host,
|
||||
port=payload.port,
|
||||
token_id=token_id,
|
||||
token_secret=token_secret,
|
||||
verify_tls=payload.verify_tls,
|
||||
)
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(status_code=502, 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 Proxmox import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc
|
||||
|
||||
nodes = [ProxmoxNodeOut(**n) for n in nodes_raw]
|
||||
edges = [ProxmoxEdgeOut(**e) for e in edges_raw]
|
||||
return ProxmoxImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
async def import_proxmox_to_pending(
|
||||
payload: ProxmoxConnectionRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Queue a Proxmox pending import as a background scan run (kind=proxmox)."""
|
||||
token_id, token_secret = _resolve_credentials(payload)
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="proxmox",
|
||||
ranges=[f"{payload.host}:{payload.port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(
|
||||
_background_proxmox_import,
|
||||
run.id,
|
||||
payload.host,
|
||||
payload.port,
|
||||
token_id,
|
||||
token_secret,
|
||||
payload.verify_tls,
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
||||
async def sync_proxmox_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Trigger an immediate Proxmox inventory sync using the server env config.
|
||||
|
||||
Same background flow as ``/import-pending`` but sources host + token from
|
||||
``settings`` (env) rather than the request body — the manual counterpart to
|
||||
the scheduled auto-sync job. Requires the env token to be configured.
|
||||
"""
|
||||
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot sync: no Proxmox host/token configured on the server.",
|
||||
)
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="proxmox",
|
||||
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(
|
||||
_background_proxmox_import,
|
||||
run.id,
|
||||
settings.proxmox_host,
|
||||
settings.proxmox_port,
|
||||
settings.proxmox_token_id,
|
||||
settings.proxmox_token_secret,
|
||||
settings.proxmox_verify_tls,
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
async def _background_proxmox_import(
|
||||
run_id: str,
|
||||
host: str,
|
||||
port: int,
|
||||
token_id: str,
|
||||
token_secret: str,
|
||||
verify_tls: bool,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
||||
host=host,
|
||||
port=port,
|
||||
token_id=token_id,
|
||||
token_secret=token_secret,
|
||||
verify_tls=verify_tls,
|
||||
)
|
||||
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)
|
||||
run.error = _guest_visibility_advisory(nodes_raw)
|
||||
await db.commit()
|
||||
# Nudge the frontend to reload the inventory (same signal the IP scan
|
||||
# emits) so imported/merged devices appear without a manual refresh.
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=result.device_count)
|
||||
except Exception as exc:
|
||||
logger.exception("Proxmox 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()
|
||||
|
||||
|
||||
def _guest_visibility_advisory(nodes_raw: list[dict[str, Any]]) -> str | None:
|
||||
"""Non-fatal advisory when hosts import but no VMs/LXC were visible.
|
||||
|
||||
A Proxmox API token that lacks ``VM.Audit`` sees empty ``qemu``/``lxc`` lists
|
||||
(HTTP 200, no error), so only host nodes come through. The usual cause is a
|
||||
privilege-separated token whose effective rights are the *intersection* with
|
||||
the user's rights — granting PVEAuditor to the token alone is not enough when
|
||||
the user has none. Surface that instead of a silent success.
|
||||
"""
|
||||
hosts = sum(1 for n in nodes_raw if n.get("type") == "proxmox")
|
||||
guests = len(nodes_raw) - hosts
|
||||
if hosts and not guests:
|
||||
return (
|
||||
f"Imported {hosts} host(s) but no VMs or LXC were visible to the API "
|
||||
"token. Grant the PVEAuditor role at path '/' to BOTH the token and "
|
||||
"the user (privilege-separated tokens get the intersection of token "
|
||||
"and user rights), then re-import."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _persist_pending_import(
|
||||
db: AsyncSession,
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ProxmoxImportPendingResponse:
|
||||
"""Upsert Proxmox nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Two-tier identity (order matters):
|
||||
1. Match an existing canvas Node or pending row by **IP** (merge into a
|
||||
device previously found by a scan) — never duplicate.
|
||||
2. Else match by synthetic ``ieee_address`` (``pve-...``).
|
||||
|
||||
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
|
||||
"""
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
|
||||
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
|
||||
|
||||
pending_created = 0
|
||||
pending_updated = 0
|
||||
|
||||
for n in nodes_raw:
|
||||
ieee = n.get("ieee_address")
|
||||
if not ieee:
|
||||
continue
|
||||
ip = n.get("ip")
|
||||
mac = normalize_mac(n.get("mac"))
|
||||
props = build_proxmox_properties(n)
|
||||
|
||||
# Match by ieee OR ip OR mac (the cross-source dedup key — a stopped VM
|
||||
# has no IP but its configured NIC MAC still matches an ARP-scanned
|
||||
# device). The inventory row owns the facts, so the import merges into
|
||||
# it whether or not the device is drawn anywhere; a node only needs its
|
||||
# cluster handles adjusted. Do NOT stomp user-set type/status.
|
||||
pending = await _find_pending(db, ieee, ip, mac)
|
||||
drawn = bool(
|
||||
pending
|
||||
and (
|
||||
await db.execute(select(Node.id).where(Node.device_id == pending.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
)
|
||||
|
||||
if pending is None:
|
||||
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
|
||||
pending_created += 1
|
||||
else:
|
||||
if drawn:
|
||||
# Already on a canvas: refresh the facts but leave the lifecycle
|
||||
# alone (_refresh_pending would revive it as pending).
|
||||
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
|
||||
else:
|
||||
_refresh_pending(pending, ieee, ip, mac, n, props)
|
||||
pending.cpu_count = pending.cpu_count or n.get("cpu_count")
|
||||
pending.ram_gb = pending.ram_gb or n.get("ram_gb")
|
||||
pending.disk_gb = pending.disk_gb or n.get("disk_gb")
|
||||
pending_updated += 1
|
||||
|
||||
# A cluster host needs one left + one right handle for the cluster
|
||||
# edge endpoints (both default to 0) — the one piece of this that is
|
||||
# genuinely about how the node is drawn.
|
||||
if ieee in cluster_members:
|
||||
for en in (
|
||||
await db.execute(select(Node).where(Node.device_id == pending.id))
|
||||
).scalars().all():
|
||||
en.left_handles = max(en.left_handles or 0, 1)
|
||||
en.right_handles = max(en.right_handles or 0, 1)
|
||||
|
||||
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
||||
await db.commit()
|
||||
|
||||
return ProxmoxImportPendingResponse(
|
||||
pending_created=pending_created,
|
||||
pending_updated=pending_updated,
|
||||
links_recorded=links_recorded,
|
||||
device_count=len(nodes_raw),
|
||||
)
|
||||
|
||||
|
||||
async def _find_pending(
|
||||
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
|
||||
) -> InventoryDevice | None:
|
||||
filters = [InventoryDevice.ieee_address == ieee]
|
||||
if ip:
|
||||
filters.append(InventoryDevice.ip == ip)
|
||||
if mac:
|
||||
filters.append(InventoryDevice.mac == mac)
|
||||
return (
|
||||
await db.execute(select(InventoryDevice).where(or_(*filters)))
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def _new_pending(
|
||||
ieee: str,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
n: dict[str, Any],
|
||||
props: list[dict[str, Any]],
|
||||
status: str,
|
||||
) -> InventoryDevice:
|
||||
return InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
hostname=n.get("hostname"),
|
||||
friendly_name=n.get("label"),
|
||||
suggested_type=n.get("type"),
|
||||
vendor=n.get("vendor"),
|
||||
model=n.get("model"),
|
||||
properties=props,
|
||||
# Specs the guest reports. They used to land only on the canvas node;
|
||||
# the inventory row owns them now.
|
||||
cpu_count=n.get("cpu_count"),
|
||||
ram_gb=n.get("ram_gb"),
|
||||
disk_gb=n.get("disk_gb"),
|
||||
status=status,
|
||||
discovery_source=_PROXMOX_GUEST_SOURCE,
|
||||
discovery_sources=[_PROXMOX_GUEST_SOURCE],
|
||||
)
|
||||
|
||||
|
||||
def _sources_after_merge(row: InventoryDevice) -> list[str]:
|
||||
"""Discovery sources for an inventory row after a Proxmox import merges in.
|
||||
|
||||
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
|
||||
whether the row was originally a scanned device. Preserves the prior scan
|
||||
origin — including legacy rows created before ``discovery_sources`` existed
|
||||
(empty list) and possibly with a NULL ``discovery_source`` — so the IP tag
|
||||
survives the merge. A row that carries an IP but was not itself a Proxmox
|
||||
device (no ``pve-`` ieee) was found by a scan; keep an IP-scan source.
|
||||
"""
|
||||
sources = add_source(row.discovery_sources, row.discovery_source)
|
||||
was_scanned = not (row.ieee_address or "").startswith("pve-")
|
||||
if was_scanned and row.ip and not any(s in ("arp", "mdns") for s in sources):
|
||||
sources = add_source(sources, "arp")
|
||||
return add_source(sources, _PROXMOX_GUEST_SOURCE)
|
||||
|
||||
|
||||
def _refresh_pending(
|
||||
pending: InventoryDevice,
|
||||
ieee: str,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
n: dict[str, Any],
|
||||
props: list[dict[str, Any]],
|
||||
) -> None:
|
||||
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
||||
pending.discovery_sources = _sources_after_merge(pending)
|
||||
pending.ieee_address = pending.ieee_address or ieee
|
||||
pending.ip = ip or pending.ip
|
||||
pending.mac = pending.mac or mac
|
||||
pending.hostname = n.get("hostname") or pending.hostname
|
||||
pending.friendly_name = n.get("label") or pending.friendly_name
|
||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
pending.model = n.get("model") or pending.model
|
||||
pending.properties = merge_proxmox_properties(list(pending.properties or []), props)
|
||||
if pending.status == "approved":
|
||||
# Approved earlier but the canvas Node is gone — revive so it reappears.
|
||||
pending.status = "pending"
|
||||
# hidden stays hidden.
|
||||
|
||||
|
||||
async def _ensure_inventory_row(
|
||||
db: AsyncSession,
|
||||
ieee: str,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
n: dict[str, Any],
|
||||
props: list[dict[str, Any]],
|
||||
approved: bool,
|
||||
) -> None:
|
||||
"""Ensure an inventory row exists for a device already on a canvas, so it
|
||||
shows in the inventory with an 'In N canvas' badge. Never changes status."""
|
||||
inv = await _find_pending(db, ieee, ip, mac)
|
||||
if inv is None:
|
||||
db.add(_new_pending(ieee, ip, mac, n, props, status="approved" if approved else "pending"))
|
||||
else:
|
||||
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
||||
inv.discovery_sources = _sources_after_merge(inv)
|
||||
inv.ieee_address = inv.ieee_address or ieee
|
||||
inv.ip = ip or inv.ip
|
||||
inv.mac = inv.mac or mac
|
||||
inv.hostname = n.get("hostname") or inv.hostname
|
||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||
inv.properties = merge_proxmox_properties(list(inv.properties or []), props)
|
||||
|
||||
|
||||
async def _replace_links(
|
||||
db: AsyncSession,
|
||||
edges_raw: list[dict[str, Any]],
|
||||
cluster_pairs: list[tuple[str, str]],
|
||||
) -> int:
|
||||
"""Wipe all proxmox-source links and re-insert the freshly discovered set.
|
||||
|
||||
Two link shapes: host→guest (``proxmox`` → 'virtual' edges) and host↔host
|
||||
(``proxmox_cluster`` → 'cluster' edges).
|
||||
"""
|
||||
await db.execute(
|
||||
sa_delete(InventoryDeviceLink).where(
|
||||
InventoryDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
||||
)
|
||||
)
|
||||
recorded = 0
|
||||
seen: set[tuple[str, str]] = set()
|
||||
|
||||
def _add(src: str | None, tgt: str | None, source: str) -> None:
|
||||
nonlocal recorded
|
||||
if not src or not tgt or (src, tgt) in seen:
|
||||
return
|
||||
seen.add((src, tgt))
|
||||
db.add(InventoryDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
||||
recorded += 1
|
||||
|
||||
for e in edges_raw:
|
||||
_add(e.get("source"), e.get("target"), _PROXMOX_GUEST_SOURCE)
|
||||
for src, tgt in cluster_pairs:
|
||||
_add(src, tgt, _PROXMOX_CLUSTER_SOURCE)
|
||||
|
||||
return recorded
|
||||
|
||||
|
||||
@router.get("/config", response_model=ProxmoxConfig)
|
||||
async def get_proxmox_config(_: str = Depends(get_current_user)) -> ProxmoxConfig:
|
||||
"""Return non-secret Proxmox config. Never includes the token — only whether
|
||||
one is configured on the server."""
|
||||
return ProxmoxConfig(
|
||||
host=settings.proxmox_host,
|
||||
port=settings.proxmox_port,
|
||||
verify_tls=settings.proxmox_verify_tls,
|
||||
sync_enabled=settings.proxmox_sync_enabled,
|
||||
sync_interval=settings.proxmox_sync_interval,
|
||||
token_configured=bool(settings.proxmox_token_id and settings.proxmox_token_secret),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ProxmoxConfig)
|
||||
async def save_proxmox_config(
|
||||
payload: ProxmoxSyncConfig,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ProxmoxConfig:
|
||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
||||
|
||||
This is the ONLY Proxmox config the app writes. Connection settings
|
||||
(host, port, token, verify_tls) are env-only and are never accepted or
|
||||
persisted here — enabling auto-sync requires host + token already set in the
|
||||
server env, since the scheduled job reads them from there.
|
||||
"""
|
||||
if payload.sync_enabled and not (
|
||||
settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot enable auto-sync: no Proxmox host/token configured in the server env.",
|
||||
)
|
||||
try:
|
||||
settings.proxmox_sync_enabled = payload.sync_enabled
|
||||
settings.proxmox_sync_interval = payload.sync_interval
|
||||
settings.save_overrides()
|
||||
set_proxmox_sync_enabled(payload.sync_enabled)
|
||||
if payload.sync_enabled:
|
||||
reschedule_proxmox_sync(payload.sync_interval)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_proxmox_config()
|
||||
@@ -0,0 +1,354 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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, Design, InventoryDevice, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.racks import (
|
||||
RackCableResponse,
|
||||
RackDeviceResponse,
|
||||
RackInventoryItem,
|
||||
RackInventoryResponse,
|
||||
RackResponse,
|
||||
RackSaveRequest,
|
||||
RackServiceInfo,
|
||||
RackStateResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Device kinds that cannot be racked. An exclusion list rather than an allow list:
|
||||
# new hardware types should show up in the tray by default, and only the obviously
|
||||
# virtual / mesh / annotation kinds need to opt out.
|
||||
_UNRACKABLE_TYPES = {
|
||||
"vm",
|
||||
"lxc",
|
||||
"docker_container",
|
||||
"mobile",
|
||||
"laptop",
|
||||
"light",
|
||||
"socket",
|
||||
"load",
|
||||
"groupRect",
|
||||
"group",
|
||||
"text",
|
||||
"zigbee_coordinator",
|
||||
"zigbee_router",
|
||||
"zigbee_enddevice",
|
||||
"zwave_coordinator",
|
||||
"zwave_router",
|
||||
"zwave_enddevice",
|
||||
}
|
||||
|
||||
|
||||
_COMMON_PORTS = {22, 80, 443}
|
||||
|
||||
|
||||
def _service_name(device: InventoryDevice) -> str | None:
|
||||
"""Name a device after the app it runs, the way the Device Inventory does.
|
||||
|
||||
Ports everyone exposes (ssh, http, https) say nothing, and the generic web
|
||||
category loses to a real match, so "jellyfin" wins over "http".
|
||||
"""
|
||||
candidates = [
|
||||
s
|
||||
for s in (device.services or [])
|
||||
if isinstance(s, dict)
|
||||
and s.get("category")
|
||||
and s.get("service_name")
|
||||
# A service with no port is not a fingerprint the inventory names a
|
||||
# device after either — and `None not in _COMMON_PORTS` is True.
|
||||
and s.get("port") is not None
|
||||
and s.get("port") not in _COMMON_PORTS
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
named = next((s for s in candidates if str(s["category"]).lower() != "web"), candidates[0])
|
||||
name = named.get("service_name")
|
||||
return str(name) if name else None
|
||||
|
||||
|
||||
def _device_label(device: InventoryDevice) -> str:
|
||||
"""Inventory naming, mirrored: friendly name, host, app, IP, IEEE, id.
|
||||
|
||||
Must stay in step with `deviceLabel` in `InventoryDevicesModal.tsx` — a device
|
||||
the user picked out of the inventory by one name should not turn up in the
|
||||
rack picker under another.
|
||||
"""
|
||||
return (
|
||||
device.friendly_name
|
||||
or device.hostname
|
||||
or _service_name(device)
|
||||
or device.ip
|
||||
or device.ieee_address
|
||||
or device.id
|
||||
)
|
||||
|
||||
|
||||
# A mount prints its services as a short list, not a port scan dump.
|
||||
_MAX_SERVICES = 12
|
||||
|
||||
|
||||
def _services(raw: Any) -> list[RackServiceInfo]:
|
||||
"""Fingerprinted services, deduped and trimmed for the rack's info panel."""
|
||||
out: list[RackServiceInfo] = []
|
||||
seen: set[tuple[int | None, str | None]] = set()
|
||||
for entry in raw or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
raw_port = entry.get("port")
|
||||
port = raw_port if isinstance(raw_port, int) else None
|
||||
raw_name = entry.get("service_name") or entry.get("name")
|
||||
name = str(raw_name) if raw_name else None
|
||||
if port is None and name is None:
|
||||
continue
|
||||
if (port, name) in seen:
|
||||
continue
|
||||
seen.add((port, name))
|
||||
out.append(RackServiceInfo(port=port, name=name))
|
||||
if len(out) == _MAX_SERVICES:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a device ``ip`` field into individual addresses.
|
||||
|
||||
Mirrors the scan routes: the canvas stores multiple addresses in one
|
||||
comma-separated string, so matching must compare per-address.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
_Row = TypeVar("_Row", Rack, RackDevice, RackCable)
|
||||
|
||||
|
||||
async def _owned(
|
||||
db: AsyncSession, model: type[_Row], row_id: str, design_id: str
|
||||
) -> _Row | None:
|
||||
"""Fetch a rack row by id, refusing one that belongs to another design.
|
||||
|
||||
The upsert below sets `design_id` from the payload, so without this a save
|
||||
carrying an id from design A would quietly move A's rack — devices and
|
||||
cables included — into design B, and A would lose it with no error. Ids come
|
||||
from the client, so a copied design or a stale tab is enough to collide.
|
||||
"""
|
||||
row = await db.get(model, row_id)
|
||||
if row is not None and row.design_id != design_id:
|
||||
raise HTTPException(409, f"{model.__name__} {row_id} belongs to another design")
|
||||
return row
|
||||
|
||||
|
||||
async def _require_design(db: AsyncSession, design_id: str) -> Design:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
return design
|
||||
|
||||
|
||||
@router.get("", response_model=RackStateResponse)
|
||||
async def load_racks(
|
||||
design_id: str = Query(..., description="Rack design to load"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> RackStateResponse:
|
||||
await _require_design(db, design_id)
|
||||
|
||||
racks = (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all()
|
||||
devices = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
|
||||
).scalars().all()
|
||||
cables = (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
|
||||
return RackStateResponse(
|
||||
racks=[RackResponse.model_validate(r) for r in racks],
|
||||
devices=[RackDeviceResponse.model_validate(d) for d in devices],
|
||||
cables=[RackCableResponse.model_validate(c) for c in cables],
|
||||
viewport=state.viewport if state else {"x": 0, "y": 0, "zoom": 1},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_racks(
|
||||
body: RackSaveRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, bool]:
|
||||
"""Persist the full rack state of one design: upsert what is sent, prune the rest."""
|
||||
await _require_design(db, body.design_id)
|
||||
|
||||
rack_ids = {r.id for r in body.racks}
|
||||
device_ids = {d.id for d in body.devices}
|
||||
|
||||
# A device must land in a rack that is part of the same payload, and a cable
|
||||
# must join two devices in it — otherwise the prune below would orphan them.
|
||||
for device in body.devices:
|
||||
if device.rack_id not in rack_ids:
|
||||
raise HTTPException(400, f"Device {device.id} references unknown rack {device.rack_id}")
|
||||
for cable in body.cables:
|
||||
if cable.from_device_id not in device_ids or cable.to_device_id not in device_ids:
|
||||
raise HTTPException(400, f"Cable {cable.id} references a device outside this payload")
|
||||
|
||||
# Prune first (cables, then devices, then racks) so freed rows never collide
|
||||
# with an upsert that reuses their geometry.
|
||||
for existing_cable in (
|
||||
await db.execute(select(RackCable).where(RackCable.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_cable.id not in {c.id for c in body.cables}:
|
||||
await db.delete(existing_cable)
|
||||
for existing_device in (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_device.id not in device_ids:
|
||||
await db.delete(existing_device)
|
||||
for existing_rack in (
|
||||
await db.execute(select(Rack).where(Rack.design_id == body.design_id))
|
||||
).scalars().all():
|
||||
if existing_rack.id not in rack_ids:
|
||||
await db.delete(existing_rack)
|
||||
await db.flush()
|
||||
|
||||
for rack_data in body.racks:
|
||||
payload: dict[str, Any] = rack_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_rack = await _owned(db, Rack, rack_data.id, body.design_id)
|
||||
if db_rack:
|
||||
for field, value in payload.items():
|
||||
setattr(db_rack, field, value)
|
||||
else:
|
||||
db.add(Rack(**payload))
|
||||
await db.flush() # racks must exist before devices point at them
|
||||
|
||||
for device_data in body.devices:
|
||||
payload = device_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_device = await _owned(db, RackDevice, device_data.id, body.design_id)
|
||||
if db_device:
|
||||
for field, value in payload.items():
|
||||
setattr(db_device, field, value)
|
||||
else:
|
||||
db.add(RackDevice(**payload))
|
||||
await db.flush() # devices must exist before cables point at them
|
||||
|
||||
for cable_data in body.cables:
|
||||
payload = cable_data.model_dump()
|
||||
payload["design_id"] = body.design_id
|
||||
db_cable = await _owned(db, RackCable, cable_data.id, body.design_id)
|
||||
if db_cable:
|
||||
for field, value in payload.items():
|
||||
setattr(db_cable, field, value)
|
||||
else:
|
||||
db.add(RackCable(**payload))
|
||||
|
||||
# Viewport lives on the design's shared CanvasState row, like the logical canvas.
|
||||
state = await db.get(CanvasState, body.design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(design_id=body.design_id, viewport=body.viewport))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
|
||||
@router.get("/inventory", response_model=RackInventoryResponse)
|
||||
async def rack_inventory(
|
||||
design_id: str = Query(..., description="Rack design the tray is for"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> RackInventoryResponse:
|
||||
"""Device Inventory entries that can be racked, flagged with what is already mounted.
|
||||
|
||||
Reads `device_inventory` — the inventory survives approval and node deletion,
|
||||
so unracking or deleting a canvas node never removes the entry here.
|
||||
"""
|
||||
await _require_design(db, design_id)
|
||||
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
mounts = (
|
||||
await db.execute(
|
||||
select(RackDevice.device_id, RackDevice.node_id).where(
|
||||
RackDevice.design_id == design_id
|
||||
)
|
||||
)
|
||||
).all()
|
||||
mounted = {device_id for device_id, _ in mounts if device_id}
|
||||
# A mount can name its canvas node itself, when the user linked one by hand
|
||||
# in the rack. That beats the IEEE/IP guess below: the guess is what failed
|
||||
# to find anything in the first place.
|
||||
pinned = {device_id: node_id for device_id, node_id in mounts if device_id and node_id}
|
||||
|
||||
# Correlate against canvas nodes: a node names the inventory row it draws,
|
||||
# so the IEEE/IP guessing this used to do is gone — the link is explicit.
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
by_id = {n.id: n for n in nodes}
|
||||
by_device: dict[str, Node] = {}
|
||||
for node in nodes:
|
||||
if node.device_id:
|
||||
by_device.setdefault(node.device_id, node)
|
||||
# Which canvas the matched node lives on — the rack prints it so the user
|
||||
# knows where the logical twin of a mount is drawn.
|
||||
design_names = {
|
||||
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
|
||||
}
|
||||
|
||||
# A pinned node may draw a *different* device than the mount names, so its
|
||||
# view is read off its own row.
|
||||
devices_by_id = {d.id: d for d in devices}
|
||||
|
||||
items = []
|
||||
for device in devices:
|
||||
if device.suggested_type in _UNRACKABLE_TYPES:
|
||||
continue
|
||||
# A node the user linked by hand wins; a deleted one falls back to the
|
||||
# node that draws this device, if any.
|
||||
linked: Node | None = by_id.get(pinned.get(device.id) or "") or by_device.get(device.id)
|
||||
# The logical view is whatever the linked node actually draws. Usually
|
||||
# that is this same row — the node_* fields then simply mirror it.
|
||||
node_device = None
|
||||
if linked is not None:
|
||||
node_device = devices_by_id.get(linked.device_id or "")
|
||||
if node_device is None and linked.device_id:
|
||||
node_device = await db.get(InventoryDevice, linked.device_id)
|
||||
items.append(
|
||||
RackInventoryItem(
|
||||
id=device.id,
|
||||
label=_device_label(device),
|
||||
suggested_type=device.suggested_type,
|
||||
ip=device.ip,
|
||||
status=device.status,
|
||||
discovery_source=device.discovery_source,
|
||||
mac=device.mac or device.ieee_address,
|
||||
hostname=device.hostname,
|
||||
os=device.os,
|
||||
services=_services(device.services),
|
||||
node_id=linked.id if linked else None,
|
||||
node_status=node_device.status_live if node_device else None,
|
||||
node_label=(
|
||||
(node_device.label if node_device else None) or linked.label
|
||||
) if linked else None,
|
||||
node_type=(
|
||||
(node_device.type if node_device else None) or linked.type
|
||||
) if linked else None,
|
||||
node_ip=node_device.ip if node_device else None,
|
||||
node_mac=(node_device.mac or node_device.ieee_address) if node_device else None,
|
||||
node_hostname=node_device.hostname if node_device else None,
|
||||
node_os=node_device.os if node_device else None,
|
||||
node_check_method=node_device.check_method if node_device else None,
|
||||
node_design_id=linked.design_id if linked else None,
|
||||
node_design_name=(
|
||||
design_names.get(linked.design_id) if linked and linked.design_id else None
|
||||
),
|
||||
node_last_seen=node_device.last_seen if node_device else None,
|
||||
racked=device.id in mounted,
|
||||
)
|
||||
)
|
||||
return RackInventoryResponse(items=items)
|
||||
+653
-94
@@ -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,21 +12,125 @@ 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 Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import (
|
||||
Design,
|
||||
Edge,
|
||||
InventoryDevice,
|
||||
InventoryDeviceLink,
|
||||
Node,
|
||||
RackDevice,
|
||||
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.zigbee_service import build_zigbee_properties
|
||||
from app.schemas.scan import (
|
||||
InventoryDeviceCreate,
|
||||
InventoryDeviceResponse,
|
||||
InventoryDeviceUpdate,
|
||||
ScanRunResponse,
|
||||
)
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device, find_duplicate_node
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
merge_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 _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a Node/device ``ip`` field into individual addresses.
|
||||
|
||||
The canvas stores multiple addresses in one comma-separated string (e.g.
|
||||
``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
|
||||
scanned device against that field must compare per-address, not against the
|
||||
whole string, or the device looks absent from the canvas (issue #258).
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
def _is_rack_only(device: InventoryDevice) -> bool:
|
||||
"""True for inventory entries created from a rack canvas.
|
||||
|
||||
They describe a mount — a patch panel, a shelf, a chassis — not a host to
|
||||
document on a logical canvas, so they are never approved onto one.
|
||||
"""
|
||||
return device.discovery_source == "rack" or "rack" in (device.discovery_sources or [])
|
||||
|
||||
|
||||
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
|
||||
@@ -37,15 +142,35 @@ 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:
|
||||
try:
|
||||
await run_scan(ranges, db, run_id)
|
||||
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()
|
||||
@@ -55,18 +180,38 @@ async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||
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
|
||||
|
||||
|
||||
@@ -86,13 +231,188 @@ 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}
|
||||
|
||||
|
||||
@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())
|
||||
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[InventoryDevice]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Correlate each device to the canvas nodes drawing it.
|
||||
|
||||
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||
on, plus aggregated node timestamps — created_at (oldest) and updated_at
|
||||
(newest). A node names the inventory row it draws, so this is a group-by on
|
||||
``device_id`` rather than the ieee/mac/ip guesswork it used to be.
|
||||
|
||||
``last_scan`` and ``last_seen`` come off the device row itself now: they are
|
||||
observations of the device, not of any one drawing of it.
|
||||
"""
|
||||
if not devices:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Node.device_id, Node.design_id, Node.created_at, Node.updated_at)
|
||||
.where(Node.design_id.isnot(None), Node.device_id.isnot(None))
|
||||
)
|
||||
).all()
|
||||
by_device: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
by_device.setdefault(row.device_id, []).append(row)
|
||||
|
||||
info: dict[str, dict[str, Any]] = {}
|
||||
for d in devices:
|
||||
matched = by_device.get(d.id, [])
|
||||
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": d.last_scan,
|
||||
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
||||
"node_last_seen": d.last_seen,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
async def _with_canvas_counts(
|
||||
db: AsyncSession, devices: list[InventoryDevice]
|
||||
) -> list[InventoryDevice]:
|
||||
"""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[InventoryDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
||||
# 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(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending", response_model=InventoryDeviceResponse, status_code=201)
|
||||
async def create_pending(
|
||||
body: InventoryDeviceCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Add an inventory entry by hand, for hardware no scan can discover.
|
||||
|
||||
Lands as ``status="pending"`` like a discovery would, so the existing approve
|
||||
/ hide / restore flows apply unchanged.
|
||||
"""
|
||||
# One device is one row. If this host is already known — by ieee, ip or mac —
|
||||
# the user is documenting the device they already have, so fill in what the
|
||||
# row is missing rather than splitting it in two.
|
||||
mac = normalize_mac(body.mac)
|
||||
existing = await find_device_for(db, ip=body.ip, mac=mac, ieee=None)
|
||||
if existing is not None:
|
||||
for field in (
|
||||
"hostname", "ip", "os", "suggested_type", "model", "vendor", "label", "type",
|
||||
"notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb", "check_method",
|
||||
"check_target", "friendly_name", "device_subtype",
|
||||
):
|
||||
value = getattr(body, field, None)
|
||||
if value not in (None, "") and getattr(existing, field, None) in (None, ""):
|
||||
setattr(existing, field, value)
|
||||
existing.mac = existing.mac or mac
|
||||
existing.properties = merge_properties(existing.properties, body.properties)
|
||||
existing.services = merge_services(existing.services, body.services)
|
||||
existing.discovery_sources = add_source(existing.discovery_sources, body.discovery_source)
|
||||
# A hidden row is still that device, so it is the one to fill in — but
|
||||
# adding a device by hand is asking for it, and returning it still hidden
|
||||
# would leave the user with a 201 and nothing in the inventory. The
|
||||
# explicit add outranks the earlier hide, exactly like restore.
|
||||
if existing.status == "hidden":
|
||||
existing.status = "pending"
|
||||
await db.commit()
|
||||
await db.refresh(existing)
|
||||
return (await _with_canvas_counts(db, [existing]))[0]
|
||||
|
||||
device = InventoryDevice(
|
||||
hostname=body.hostname,
|
||||
ip=body.ip,
|
||||
# Canonical form, like every other write path: dedup compares MACs by
|
||||
# equality, so a hand-typed "AA-BB-CC-11-22-33" would never match the
|
||||
# scanned "aa:bb:cc:11:22:33" and approve would build a duplicate node.
|
||||
mac=mac,
|
||||
suggested_type=body.suggested_type,
|
||||
model=body.model,
|
||||
vendor=body.vendor,
|
||||
properties=body.properties,
|
||||
status="pending",
|
||||
discovery_source=body.discovery_source,
|
||||
discovery_sources=[body.discovery_source],
|
||||
os=body.os,
|
||||
services=body.services,
|
||||
friendly_name=body.friendly_name,
|
||||
device_subtype=body.device_subtype,
|
||||
label=body.label,
|
||||
type=body.type,
|
||||
notes=body.notes,
|
||||
cpu_count=body.cpu_count,
|
||||
cpu_model=body.cpu_model,
|
||||
ram_gb=body.ram_gb,
|
||||
disk_gb=body.disk_gb,
|
||||
show_hardware=body.show_hardware,
|
||||
check_method=body.check_method,
|
||||
check_target=body.check_target,
|
||||
)
|
||||
db.add(device)
|
||||
await db.commit()
|
||||
await db.refresh(device)
|
||||
return (await _with_canvas_counts(db, [device]))[0]
|
||||
|
||||
|
||||
@router.patch("/pending/{device_id}", response_model=InventoryDeviceResponse)
|
||||
async def update_pending(
|
||||
device_id: str,
|
||||
body: InventoryDeviceUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Edit an inventory row — the device facts, not its lifecycle.
|
||||
|
||||
The inventory row owns what a device *is*; a canvas node only owns how it is
|
||||
drawn. This is the write path behind the device detail modal, and applies
|
||||
only the fields the client actually sent.
|
||||
"""
|
||||
device = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
|
||||
).scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
if "mac" in data:
|
||||
# Same reason as create_pending: dedup matches MACs by equality.
|
||||
data["mac"] = normalize_mac(data["mac"])
|
||||
for field, value in data.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(device)
|
||||
return (await _with_canvas_counts(db, [device]))[0]
|
||||
|
||||
|
||||
@router.delete("/pending", response_model=dict)
|
||||
@@ -101,15 +421,46 @@ async def clear_pending(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
||||
result = await db.execute(sa_delete(InventoryDevice).where(InventoryDevice.status == "pending"))
|
||||
await db.commit()
|
||||
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())
|
||||
@router.delete("/pending/{device_id}", response_model=dict)
|
||||
async def delete_pending(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, bool]:
|
||||
"""Remove one inventory entry.
|
||||
|
||||
Used by the rack canvas to drop the placeholder it created for a plate once
|
||||
that plate is pointed at a real inventory row. Refuses while a rack still
|
||||
mounts the device: SQLite runs with foreign keys off here, so the mount's
|
||||
``device_id`` would be left naming a row that no longer exists rather than
|
||||
being cleared by the ``ON DELETE SET NULL`` the schema declares.
|
||||
"""
|
||||
device = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
|
||||
).scalar_one_or_none()
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
mounted = (
|
||||
await db.execute(select(RackDevice.id).where(RackDevice.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
if mounted:
|
||||
raise HTTPException(status_code=409, detail="Device is mounted in a rack")
|
||||
|
||||
await db.delete(device)
|
||||
await db.commit()
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.get("/hidden", response_model=list[InventoryDeviceResponse])
|
||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[InventoryDevice]:
|
||||
result = await db.execute(select(InventoryDevice).where(InventoryDevice.status == "hidden"))
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
@@ -118,47 +469,121 @@ async def bulk_approve_devices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Determine target design (use first design as fallback)
|
||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
default_design_id = first_design.id if first_design else None
|
||||
# Repair any legacy same-canvas duplicate nodes before placing more.
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# 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 == "pending",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
# What already sits on the target canvas. A node names the inventory row it
|
||||
# draws, so "already placed" is now a device-id lookup rather than a guess
|
||||
# across ip/mac/ieee. The value may be a Node still pending flush (in-batch
|
||||
# duplicate) — resolved to its id after the flush below.
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node.id, Node.device_id).where(
|
||||
Node.design_id == default_design_id, Node.device_id.isnot(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
placed: dict[str, Any] = {device_id: nid for nid, device_id in existing}
|
||||
|
||||
created_nodes: list[Node] = []
|
||||
approved_devices: list[InventoryDevice] = []
|
||||
skipped_devices: list[dict[str, Any]] = []
|
||||
for device in devices:
|
||||
# Rack-only gear belongs to a rack canvas, never to a logical one.
|
||||
if _is_rack_only(device):
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or "device",
|
||||
"match": "rack", "value": "rack device", "_ref": None,
|
||||
})
|
||||
continue
|
||||
# Report which identifier names the device, so the caller can explain
|
||||
# the skip and link to the node already drawing it.
|
||||
if device.id in placed:
|
||||
match, value = (
|
||||
("ip", device.ip) if device.ip
|
||||
else ("ieee", device.ieee_address) if device.ieee_address
|
||||
else ("mac", device.mac)
|
||||
)
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or value or "device",
|
||||
"match": match, "value": value, "_ref": placed[device.id],
|
||||
})
|
||||
continue
|
||||
device.status = "approved"
|
||||
node_type = device.suggested_type or "generic"
|
||||
is_zigbee = node_type in _ZIGBEE_TYPES
|
||||
is_wireless = _is_wireless(node_type)
|
||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||
# Enrich the row, not the node: a mesh device's radio properties and the
|
||||
# default check method describe the device itself.
|
||||
device.type = device.type or node_type
|
||||
device.label = device.label or device.hostname or device.friendly_name or device.ip or "device"
|
||||
device.properties = (
|
||||
_wireless_properties(node_type, device.ieee_address, device.vendor, device.model, device.lqi)
|
||||
if is_wireless
|
||||
else merge_mac_property(list(device.properties or []), device.mac)
|
||||
)
|
||||
if is_wireless:
|
||||
# A mesh device answers no ICMP; being in the mesh is the liveness.
|
||||
device.check_method = "none"
|
||||
device.status_live = "online"
|
||||
elif not device.check_method:
|
||||
# Default to ping so the status checker actually polls it. Without
|
||||
# this the scheduler skips it (check_method NULL -> no check).
|
||||
device.check_method = "ping" if device.ip else None
|
||||
node = Node(
|
||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_zigbee else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_zigbee else [],
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_zigbee else ("ping" if device.ip else None),
|
||||
label=device.label,
|
||||
type=device.type,
|
||||
# Cluster hosts get side handles for their host<->host cluster edge.
|
||||
left_handles=1 if cluster_host else 0,
|
||||
right_handles=1 if cluster_host else 0,
|
||||
design_id=default_design_id,
|
||||
# The node draws this inventory row; the row owns the facts.
|
||||
device_id=device.id,
|
||||
)
|
||||
db.add(node)
|
||||
created_nodes.append(node)
|
||||
approved_devices.append(device)
|
||||
# Track within this batch so a duplicate selection is not placed twice
|
||||
# on the same canvas. Stores the Node so a later in-batch skip can
|
||||
# resolve to its id after flush.
|
||||
placed[device.id] = node
|
||||
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 devices]
|
||||
approved_device_ids = [d.id for d in approved_devices]
|
||||
|
||||
# Resolve each skip's existing-node reference to a concrete id now that any
|
||||
# in-batch nodes have been flushed, and expose it under a clean key.
|
||||
for entry in skipped_devices:
|
||||
ref = entry.pop("_ref")
|
||||
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
|
||||
|
||||
all_edges: list[dict[str, str]] = []
|
||||
for device in devices:
|
||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
||||
for device in approved_devices:
|
||||
all_edges.extend(
|
||||
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
@@ -168,6 +593,7 @@ async def bulk_approve_devices(
|
||||
"edges_created": len(all_edges),
|
||||
"edges": all_edges,
|
||||
"skipped": len(payload.device_ids) - len(node_ids),
|
||||
"skipped_devices": skipped_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -178,9 +604,9 @@ async def bulk_hide_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "pending",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -196,7 +622,7 @@ async def restore_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "hidden":
|
||||
@@ -213,9 +639,9 @@ async def bulk_restore_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "hidden",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -238,33 +664,95 @@ async def approve_device(
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
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")
|
||||
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
||||
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
|
||||
# design must therefore work even when the device already sits on another
|
||||
# one (mirroring bulk_approve_devices, which deliberately does not filter on
|
||||
# status == "pending"). Same-design duplicates are caught by the per-design
|
||||
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
|
||||
# device is off-limits here.
|
||||
if device.status == "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
||||
if _is_rack_only(device):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Rack devices cannot be placed on a logical canvas",
|
||||
)
|
||||
wireless = _is_wireless(node_data.type)
|
||||
|
||||
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
|
||||
# again automatically: the user might genuinely want a second card, or might
|
||||
# be re-approving by mistake. Reject with 409 + the existing node so the UI
|
||||
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
|
||||
# hosts. force=True (set after the user confirms) skips this and creates it.
|
||||
# The same device on a *different* design is valid (one Node per canvas), so
|
||||
# this is scoped to node_design_id.
|
||||
if not node_data.force:
|
||||
conflict = await find_duplicate_node(
|
||||
db, node_design_id,
|
||||
node_data.ip or device.ip,
|
||||
node_data.mac or device.mac,
|
||||
ieee=device.ieee_address,
|
||||
)
|
||||
if conflict is not None:
|
||||
raise HTTPException(status_code=409, detail=conflict)
|
||||
|
||||
device.status = "approved"
|
||||
_is_zigbee = node_data.type in _ZIGBEE_TYPES
|
||||
# Prefer the MAC discovered during the scan (stored on the inventory row);
|
||||
# fall back to whatever the approve payload carried.
|
||||
_mac = device.mac or node_data.mac
|
||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||
|
||||
# The approve dialog is an edit of the device, so its values land on the row
|
||||
# — the node that follows only says where it is drawn. A blank field in the
|
||||
# payload never clears what discovery already found.
|
||||
device.label = node_data.label or device.label
|
||||
device.type = node_data.type or device.type
|
||||
device.ip = node_data.ip or device.ip
|
||||
device.mac = _mac
|
||||
device.hostname = node_data.hostname or device.hostname
|
||||
device.services = merge_services(device.services, node_data.services)
|
||||
device.properties = (
|
||||
_wireless_properties(
|
||||
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
)
|
||||
if wireless
|
||||
else merge_mac_property(
|
||||
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
|
||||
_mac,
|
||||
)
|
||||
)
|
||||
if wireless:
|
||||
# A mesh device answers no ICMP; being in the mesh is the liveness.
|
||||
device.check_method = "none"
|
||||
device.check_target = None
|
||||
device.status_live = "online"
|
||||
else:
|
||||
device.check_method = node_data.check_method or device.check_method or (
|
||||
"ping" if device.ip else None
|
||||
)
|
||||
device.check_target = node_data.check_target or device.check_target
|
||||
if node_data.status:
|
||||
device.status_live = node_data.status
|
||||
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if _is_zigbee else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=build_zigbee_properties(
|
||||
device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if _is_zigbee else (node_data.properties or []),
|
||||
check_method="none" if _is_zigbee else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if _is_zigbee else node_data.check_target,
|
||||
label=device.label or node_data.label,
|
||||
type=device.type or node_data.type,
|
||||
# Cluster hosts get side handles for their host<->host cluster edge.
|
||||
left_handles=1 if cluster_host else 0,
|
||||
right_handles=1 if cluster_host else 0,
|
||||
design_id=node_design_id,
|
||||
# The node draws this inventory row; the row owns the facts.
|
||||
device_id=device.id,
|
||||
)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
node_id = node.id
|
||||
|
||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
@@ -275,39 +763,72 @@ async def approve_device(
|
||||
}
|
||||
|
||||
|
||||
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``.
|
||||
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
|
||||
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
|
||||
|
||||
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.
|
||||
Such a host needs one left + one right handle for the cluster edge endpoints
|
||||
(both default to 0). Checked at approve time, before the link is consumed by
|
||||
``_resolve_pending_links_for_ieee``.
|
||||
"""
|
||||
if not ieee:
|
||||
return False
|
||||
found = (
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink.id)
|
||||
.where(
|
||||
InventoryDeviceLink.discovery_source == "proxmox_cluster",
|
||||
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar()
|
||||
return found is not None
|
||||
|
||||
|
||||
async def _resolve_pending_links_for_ieee(
|
||||
db: AsyncSession, ieee: str | None, design_id: str | None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Materialize edges for any device_inventory_links involving ``ieee`` on the
|
||||
canvas identified by ``design_id``.
|
||||
|
||||
For each link where the other endpoint already exists as a Node *on this
|
||||
design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
|
||||
Edge. Links are **never** deleted here: they describe the discovered mesh /
|
||||
cluster topology and are wiped+reinserted wholesale on the next import
|
||||
(zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
|
||||
onto a second canvas with their edges intact.
|
||||
"""
|
||||
if not ieee:
|
||||
return []
|
||||
|
||||
links_q = await db.execute(
|
||||
select(PendingDeviceLink).where(
|
||||
(PendingDeviceLink.source_ieee == ieee)
|
||||
| (PendingDeviceLink.target_ieee == ieee)
|
||||
select(InventoryDeviceLink).where(
|
||||
(InventoryDeviceLink.source_ieee == ieee)
|
||||
| (InventoryDeviceLink.target_ieee == ieee)
|
||||
)
|
||||
)
|
||||
links = list(links_q.scalars().all())
|
||||
if not links:
|
||||
return []
|
||||
|
||||
# Map every relevant ieee → Node (single query).
|
||||
# Map every relevant ieee → Node *on the target design* (single query).
|
||||
# Scoping by design is what makes a re-approve onto a second canvas link the
|
||||
# nodes of THAT canvas, not stale nodes left on another one.
|
||||
other_ieees = {
|
||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||
for link in links
|
||||
}
|
||||
other_ieees.add(ieee)
|
||||
# A node reaches its ieee through the inventory row it draws.
|
||||
nodes_q = await db.execute(
|
||||
select(Node).where(Node.ieee_address.in_(other_ieees))
|
||||
select(InventoryDevice.ieee_address, Node)
|
||||
.join(Node, Node.device_id == InventoryDevice.id)
|
||||
.where(
|
||||
InventoryDevice.ieee_address.in_(other_ieees),
|
||||
Node.design_id == design_id,
|
||||
)
|
||||
)
|
||||
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||
by_ieee = {row_ieee: node for row_ieee, node in nodes_q.all() if row_ieee}
|
||||
|
||||
self_node = by_ieee.get(ieee)
|
||||
if self_node is None:
|
||||
@@ -336,28 +857,48 @@ async def _resolve_pending_links_for_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.
|
||||
# Skip if either direction already exists on this design (re-approve or
|
||||
# a manually drawn link). The link row is kept for other designs.
|
||||
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 lands on the design we're approving into (the nodes above are
|
||||
# already scoped to it).
|
||||
edge_design_id = design_id or (self_node.design_id if self_node else None)
|
||||
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
|
||||
# (the canonical stored form — the save path normalizes '<side>-t' → the
|
||||
# bare source id, and React Flow resolves the bare id to that side). A
|
||||
# '-t' target id does not resolve here and RF falls back to the top
|
||||
# handle, so never emit one.
|
||||
# proxmox → 'virtual' host→guest, vertical (bottom → top)
|
||||
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
|
||||
# anything else → 'iot' mesh link, vertical
|
||||
if link.discovery_source == "proxmox":
|
||||
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
|
||||
elif link.discovery_source == "proxmox_cluster":
|
||||
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
|
||||
else:
|
||||
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
|
||||
edge = Edge(
|
||||
source=src_id,
|
||||
target=tgt_id,
|
||||
type="iot",
|
||||
source_handle="bottom",
|
||||
target_handle="top-t",
|
||||
type=edge_type,
|
||||
source_handle=src_handle,
|
||||
target_handle=tgt_handle,
|
||||
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 the edge's type + handles so the client injects it faithfully
|
||||
# (a cluster edge must keep its right→left handles, not the iot default).
|
||||
created.append({
|
||||
"id": edge.id,
|
||||
"source": src_id,
|
||||
"target": tgt_id,
|
||||
"type": edge_type,
|
||||
"source_handle": src_handle,
|
||||
"target_handle": tgt_handle,
|
||||
})
|
||||
|
||||
return created
|
||||
|
||||
@@ -366,7 +907,7 @@ async def _resolve_pending_links_for_ieee(
|
||||
async def hide_device(
|
||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
device.status = "hidden"
|
||||
@@ -378,7 +919,7 @@ async def hide_device(
|
||||
async def ignore_device(
|
||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
await db.delete(device)
|
||||
@@ -394,17 +935,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
|
||||
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.save_overrides()
|
||||
return payload
|
||||
except Exception as exc:
|
||||
settings.scanner_ranges = previous
|
||||
(
|
||||
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,33 @@
|
||||
"""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,
|
||||
reschedule_status_checks,
|
||||
set_service_checks_enabled,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
interval_seconds: int
|
||||
# Mirrors the floor enforced by reschedule_status_checks(): rejecting here
|
||||
# returns 422 instead of persisting the value and then failing with a 500.
|
||||
interval_seconds: int = Field(ge=10)
|
||||
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 +36,17 @@ 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 status-check schedule live. Without this the new interval is
|
||||
# persisted and shown in the UI, but the running job keeps the old one
|
||||
# until the backend restarts.
|
||||
reschedule_status_checks(payload.interval_seconds)
|
||||
# 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
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -30,22 +30,38 @@ async def summary(
|
||||
"""
|
||||
_check_key(x_api_key)
|
||||
|
||||
# A node's reachability lives on the Device Inventory row it draws, so the
|
||||
# per-canvas counts come from the join. A node with no row (canvas
|
||||
# furniture) counts as unknown.
|
||||
status_rows = (
|
||||
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||
await db.execute(
|
||||
select(InventoryDevice.status_live, func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.group_by(InventoryDevice.status_live)
|
||||
)
|
||||
).all()
|
||||
counts = {row[0]: row[1] for row in status_rows}
|
||||
counts = {(row[0] or "unknown"): row[1] for row in status_rows}
|
||||
unlinked = (
|
||||
await db.execute(select(func.count()).select_from(Node).where(Node.device_id.is_(None)))
|
||||
).scalar_one()
|
||||
if unlinked:
|
||||
counts["unknown"] = counts.get("unknown", 0) + unlinked
|
||||
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PendingDevice)
|
||||
.where(PendingDevice.status == "pending")
|
||||
.select_from(InventoryDevice)
|
||||
.where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
zigbee = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||
select(func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.where(InventoryDevice.ieee_address.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -58,6 +74,8 @@ async def summary(
|
||||
"online": counts.get("online", 0),
|
||||
"offline": counts.get("offline", 0),
|
||||
"unknown": counts.get("unknown", 0),
|
||||
# Wire key kept from before the table was renamed to `device_inventory`:
|
||||
# the stats payload is a published contract, and the MCP server reads it.
|
||||
"pending_devices": pending,
|
||||
"zigbee_devices": zigbee,
|
||||
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import contextlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.core.security import decode_token
|
||||
from app.core.config import settings
|
||||
from app.core.security import (
|
||||
decode_oidc_session_token,
|
||||
decode_token,
|
||||
oidc_session_cookie_name,
|
||||
origin_is_allowed,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -10,30 +17,50 @@ 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
|
||||
await websocket.accept()
|
||||
try:
|
||||
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
if not origin_is_allowed(websocket.headers.get("origin")):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
|
||||
if settings.auth_mode == "oidc":
|
||||
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
|
||||
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
else:
|
||||
# Local mode keeps the existing first-message bearer protocol.
|
||||
try:
|
||||
raw = await websocket.receive_text()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
token = payload.get("token", "")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
token = ""
|
||||
if not token or not decode_token(token):
|
||||
await websocket.close(code=1008) # Policy Violation
|
||||
return
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
_connections.append(websocket)
|
||||
try:
|
||||
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,19 +68,44 @@ 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:
|
||||
async def broadcast_status(
|
||||
device_id: str,
|
||||
node_ids: list[str],
|
||||
status: str,
|
||||
checked_at: str,
|
||||
response_time_ms: int | None = None,
|
||||
) -> None:
|
||||
"""Report one device's reachability to every canvas drawing it.
|
||||
|
||||
A device is checked once, not once per canvas, so the result is addressed by
|
||||
device and carries the nodes it lights up. ``node_ids`` is empty for a
|
||||
device that is in the inventory but on no canvas.
|
||||
"""
|
||||
await _broadcast(json.dumps({
|
||||
"type": "status",
|
||||
"node_id": node_id,
|
||||
"device_id": device_id,
|
||||
"node_ids": node_ids,
|
||||
"status": status,
|
||||
"checked_at": checked_at,
|
||||
"response_time_ms": response_time_ms,
|
||||
}))
|
||||
|
||||
|
||||
async def broadcast_service_status(
|
||||
device_id: str, node_ids: list[str], services: list[dict[str, object]], checked_at: str
|
||||
) -> None:
|
||||
await _broadcast(json.dumps({
|
||||
"type": "service_status",
|
||||
"device_id": device_id,
|
||||
"node_ids": node_ids,
|
||||
"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",
|
||||
|
||||
@@ -10,19 +10,24 @@ 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.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeConfig,
|
||||
ZigbeeCoordinatorOut,
|
||||
ZigbeeEdgeOut,
|
||||
ZigbeeImportPendingResponse,
|
||||
ZigbeeImportRequest,
|
||||
ZigbeeImportResponse,
|
||||
ZigbeeNodeOut,
|
||||
ZigbeeSyncConfig,
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
fetch_networkmap,
|
||||
@@ -31,6 +36,15 @@ from app.services.zigbee_service import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -98,6 +112,53 @@ async def import_zigbee_to_pending(
|
||||
return run
|
||||
|
||||
|
||||
def env_import_request() -> ZigbeeImportRequest:
|
||||
"""Build an import request from the server env config (for auto-sync).
|
||||
|
||||
MQTT credentials live in the env only — never in the request body or any
|
||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
||||
their connection settings here so there is a single source of truth."""
|
||||
return ZigbeeImportRequest(
|
||||
mqtt_host=settings.zigbee_mqtt_host,
|
||||
mqtt_port=settings.zigbee_mqtt_port,
|
||||
mqtt_username=settings.zigbee_mqtt_username or None,
|
||||
mqtt_password=settings.zigbee_mqtt_password or None,
|
||||
base_topic=settings.zigbee_base_topic,
|
||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
||||
mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
||||
async def sync_zigbee_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Trigger an immediate Zigbee import using the server env config.
|
||||
|
||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
||||
from ``settings`` (env) rather than the request body — the manual
|
||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
||||
"""
|
||||
if not settings.zigbee_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot sync: no Zigbee MQTT host configured on the server.",
|
||||
)
|
||||
payload = env_import_request()
|
||||
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:
|
||||
@@ -133,15 +194,17 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZigbeeImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert nodes/edges into device_inventory + device_inventory_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
|
||||
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
|
||||
# the by-IEEE lookups below resolve to a single row.
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
||||
# stay unset — retained for backward-compatible response shape.
|
||||
coordinator_out: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_existed = False
|
||||
pending_created = 0
|
||||
@@ -155,58 +218,21 @@ async def _persist_pending_import(
|
||||
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
|
||||
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -215,6 +241,7 @@ async def _persist_pending_import(
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
properties=props,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
@@ -228,14 +255,20 @@ async def _persist_pending_import(
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
if pending.status == "hidden":
|
||||
pending.properties = merge_zigbee_properties(list(pending.properties or []), props)
|
||||
if pending.status == "approved" and not await _is_drawn(db, pending.id):
|
||||
# Approved earlier but no canvas draws it any more — the node was
|
||||
# deleted. Revive the row to "pending" so it reappears in the 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")
|
||||
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
@@ -247,7 +280,7 @@ async def _persist_pending_import(
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zigbee",
|
||||
@@ -290,3 +323,50 @@ async def test_zigbee_connection(
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
|
||||
|
||||
@router.get("/config", response_model=ZigbeeConfig)
|
||||
async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig:
|
||||
"""Return non-secret Zigbee config. Never includes MQTT credentials — only
|
||||
whether a host is configured on the server for auto-sync."""
|
||||
return ZigbeeConfig(
|
||||
mqtt_host=settings.zigbee_mqtt_host,
|
||||
mqtt_port=settings.zigbee_mqtt_port,
|
||||
base_topic=settings.zigbee_base_topic,
|
||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
||||
sync_enabled=settings.zigbee_sync_enabled,
|
||||
sync_interval=settings.zigbee_sync_interval,
|
||||
host_configured=bool(settings.zigbee_mqtt_host),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ZigbeeConfig)
|
||||
async def save_zigbee_config(
|
||||
payload: ZigbeeSyncConfig,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeConfig:
|
||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
||||
|
||||
This is the ONLY Zigbee config the app writes. Connection settings
|
||||
(host/port/credentials/topic/tls) are env-only and are never accepted or
|
||||
persisted here — enabling auto-sync requires the MQTT host already set in
|
||||
the server env, since the scheduled job reads it from there.
|
||||
"""
|
||||
if payload.sync_enabled and not settings.zigbee_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.",
|
||||
)
|
||||
try:
|
||||
settings.zigbee_sync_enabled = payload.sync_enabled
|
||||
settings.zigbee_sync_interval = payload.sync_interval
|
||||
settings.save_overrides()
|
||||
set_zigbee_sync_enabled(payload.sync_enabled)
|
||||
if payload.sync_enabled:
|
||||
reschedule_zigbee_sync(payload.sync_interval)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_zigbee_config()
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
"""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.core.config import settings
|
||||
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zwave import (
|
||||
ZwaveConfig,
|
||||
ZwaveCoordinatorOut,
|
||||
ZwaveEdgeOut,
|
||||
ZwaveImportPendingResponse,
|
||||
ZwaveImportRequest,
|
||||
ZwaveImportResponse,
|
||||
ZwaveNodeOut,
|
||||
ZwaveSyncConfig,
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zwave_service import (
|
||||
build_zwave_properties,
|
||||
fetch_zwave_network,
|
||||
merge_zwave_properties,
|
||||
test_zwave_connection,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def env_import_request() -> ZwaveImportRequest:
|
||||
"""Build an import request from the server env config (for auto-sync).
|
||||
|
||||
MQTT credentials live in the env only — never in the request body or any
|
||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
||||
their connection settings here so there is a single source of truth."""
|
||||
return ZwaveImportRequest(
|
||||
mqtt_host=settings.zwave_mqtt_host,
|
||||
mqtt_port=settings.zwave_mqtt_port,
|
||||
mqtt_username=settings.zwave_mqtt_username or None,
|
||||
mqtt_password=settings.zwave_mqtt_password or None,
|
||||
prefix=settings.zwave_prefix,
|
||||
gateway_name=settings.zwave_gateway_name,
|
||||
mqtt_tls=settings.zwave_mqtt_tls,
|
||||
mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
||||
async def sync_zwave_now(
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Trigger an immediate Z-Wave import using the server env config.
|
||||
|
||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
||||
from ``settings`` (env) rather than the request body — the manual
|
||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
||||
"""
|
||||
if not settings.zwave_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot sync: no Z-Wave MQTT host configured on the server.",
|
||||
)
|
||||
payload = env_import_request()
|
||||
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 device_inventory + device_inventory_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.
|
||||
"""
|
||||
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
|
||||
# the by-IEEE lookups below resolve cleanly.
|
||||
await dedupe_nodes_by_device(db)
|
||||
|
||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
||||
# stay unset — retained for backward-compatible response shape.
|
||||
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"))
|
||||
|
||||
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
InventoryDevice(
|
||||
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"),
|
||||
properties=props,
|
||||
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
|
||||
pending.properties = merge_zwave_properties(list(pending.properties or []), props)
|
||||
if pending.status == "approved" and not await _is_drawn(db, pending.id):
|
||||
# Approved earlier but no canvas draws it any more (the node was
|
||||
# deleted) — revive to "pending" so it reappears in the list.
|
||||
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(InventoryDeviceLink).where(InventoryDeviceLink.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(
|
||||
InventoryDeviceLink(
|
||||
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")
|
||||
|
||||
|
||||
@router.get("/config", response_model=ZwaveConfig)
|
||||
async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig:
|
||||
"""Return non-secret Z-Wave config. Never includes MQTT credentials — only
|
||||
whether a host is configured on the server for auto-sync."""
|
||||
return ZwaveConfig(
|
||||
mqtt_host=settings.zwave_mqtt_host,
|
||||
mqtt_port=settings.zwave_mqtt_port,
|
||||
prefix=settings.zwave_prefix,
|
||||
gateway_name=settings.zwave_gateway_name,
|
||||
mqtt_tls=settings.zwave_mqtt_tls,
|
||||
sync_enabled=settings.zwave_sync_enabled,
|
||||
sync_interval=settings.zwave_sync_interval,
|
||||
host_configured=bool(settings.zwave_mqtt_host),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config", response_model=ZwaveConfig)
|
||||
async def save_zwave_config(
|
||||
payload: ZwaveSyncConfig,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZwaveConfig:
|
||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
||||
|
||||
This is the ONLY Z-Wave config the app writes. Connection settings
|
||||
(host/port/credentials/prefix/gateway/tls) are env-only and are never
|
||||
accepted or persisted here — enabling auto-sync requires the MQTT host
|
||||
already set in the server env, since the scheduled job reads it from there.
|
||||
"""
|
||||
if payload.sync_enabled and not settings.zwave_mqtt_host:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.",
|
||||
)
|
||||
try:
|
||||
settings.zwave_sync_enabled = payload.sync_enabled
|
||||
settings.zwave_sync_interval = payload.sync_interval
|
||||
settings.save_overrides()
|
||||
set_zwave_sync_enabled(payload.sync_enabled)
|
||||
if payload.sync_enabled:
|
||||
reschedule_zwave_sync(payload.sync_interval)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_zwave_config()
|
||||
+153
-2
@@ -1,8 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,6 +25,10 @@ class Settings(BaseSettings):
|
||||
|
||||
secret_key: str # Required — set SECRET_KEY in .env
|
||||
sqlite_path: str = "./data/homelab.db"
|
||||
# Uploaded media (floor plans, and future raw image uploads) live on disk,
|
||||
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
|
||||
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
|
||||
upload_dir: str = ""
|
||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# JWT
|
||||
@@ -31,11 +36,22 @@ class Settings(BaseSettings):
|
||||
access_token_expire_minutes: int = 1440 # 24h
|
||||
|
||||
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
|
||||
auth_mode: Literal["local", "oidc"] = "local"
|
||||
auth_username: str = "admin"
|
||||
auth_password_hash: str = ""
|
||||
|
||||
# OpenID Connect — required only when AUTH_MODE=oidc.
|
||||
oidc_discovery_url: str = ""
|
||||
oidc_client_id: str = ""
|
||||
oidc_client_secret: str = ""
|
||||
oidc_redirect_uri: str = ""
|
||||
oidc_scopes: str = "openid profile email"
|
||||
oidc_cookie_secure: bool = True
|
||||
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
|
||||
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_password_hash(self) -> "Settings":
|
||||
def validate_auth_settings(self) -> "Settings":
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
@@ -43,14 +59,58 @@ class Settings(BaseSettings):
|
||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
||||
)
|
||||
if self.auth_mode == "oidc":
|
||||
required = {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_CLIENT_ID": self.oidc_client_id,
|
||||
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}
|
||||
missing = [name for name, value in required.items() if not value]
|
||||
if missing:
|
||||
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
|
||||
if len(self.secret_key.encode("utf-8")) < 32:
|
||||
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
|
||||
if "openid" not in self.oidc_scopes.split():
|
||||
raise ValueError("OIDC_SCOPES must contain 'openid'")
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith(("https://", "http://")):
|
||||
raise ValueError(f"{name} must be an HTTP(S) URL")
|
||||
if self.oidc_cookie_secure:
|
||||
for name, value in {
|
||||
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
|
||||
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
|
||||
}.items():
|
||||
if not value.startswith("https://"):
|
||||
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
|
||||
if "*" in self.cors_origins:
|
||||
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||
|
||||
# Phase-2 version-detection (-sV) host timeout, seconds. Bounds the version
|
||||
# pass so a stalling TLS port (e.g. Proxmox 8006) can't hang it. Discovered
|
||||
# ports survive a timeout regardless; raise this on slow/overlay networks.
|
||||
scanner_version_host_timeout: int = 60
|
||||
|
||||
# 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.
|
||||
@@ -66,9 +126,55 @@ class Settings(BaseSettings):
|
||||
# Leave empty to keep the feature disabled (default).
|
||||
homepage_api_key: str = ""
|
||||
|
||||
# Proxmox VE import.
|
||||
# Token = a real credential → env/.env ONLY, never persisted by the app to
|
||||
# scan_config.json and never returned by the API. token_id is
|
||||
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
|
||||
proxmox_token_id: str = ""
|
||||
proxmox_token_secret: str = ""
|
||||
# Non-secret connection + auto-sync config (persisted via save_overrides).
|
||||
proxmox_host: str = ""
|
||||
proxmox_port: int = 8006
|
||||
proxmox_verify_tls: bool = True
|
||||
proxmox_sync_enabled: bool = False
|
||||
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Zigbee2MQTT auto-sync import.
|
||||
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
|
||||
# scan_config.json and never returned by the API. Only the auto-sync
|
||||
# activation (enabled + interval) is persisted; connection config is env-only.
|
||||
zigbee_mqtt_host: str = ""
|
||||
zigbee_mqtt_port: int = 1883
|
||||
zigbee_mqtt_username: str = ""
|
||||
zigbee_mqtt_password: str = ""
|
||||
zigbee_base_topic: str = "zigbee2mqtt"
|
||||
zigbee_mqtt_tls: bool = False
|
||||
zigbee_mqtt_tls_insecure: bool = False
|
||||
zigbee_sync_enabled: bool = False
|
||||
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
||||
zwave_mqtt_host: str = ""
|
||||
zwave_mqtt_port: int = 1883
|
||||
zwave_mqtt_username: str = ""
|
||||
zwave_mqtt_password: str = ""
|
||||
zwave_prefix: str = "zwave"
|
||||
zwave_gateway_name: str = "zwavejs2mqtt"
|
||||
zwave_mqtt_tls: bool = False
|
||||
zwave_mqtt_tls_insecure: bool = False
|
||||
zwave_sync_enabled: bool = False
|
||||
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
def media_dir(self) -> Path:
|
||||
"""On-disk folder for uploaded media. Sits on the same persistent
|
||||
volume as the SQLite DB unless UPLOAD_DIR is set."""
|
||||
if self.upload_dir:
|
||||
return Path(self.upload_dir)
|
||||
return Path(self.sqlite_path).parent / "uploads"
|
||||
|
||||
def load_overrides(self) -> None:
|
||||
"""Load runtime scan config overrides written by the API."""
|
||||
try:
|
||||
@@ -77,6 +183,35 @@ 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"])
|
||||
# Proxmox auto-sync activation only. Connection config (host, port,
|
||||
# token, verify_tls) is env-only by design — never read from or
|
||||
# written to this file. Persisting host here previously created a
|
||||
# dual source of truth that silently clobbered PROXMOX_HOST.
|
||||
if "proxmox_sync_enabled" in data:
|
||||
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
|
||||
if "proxmox_sync_interval" in data:
|
||||
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
|
||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
||||
# connection config (host, port, credentials, topic, tls) is env-only
|
||||
# by design — never read from or written to this file.
|
||||
if "zigbee_sync_enabled" in data:
|
||||
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
|
||||
if "zigbee_sync_interval" in data:
|
||||
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
|
||||
if "zwave_sync_enabled" in data:
|
||||
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
|
||||
if "zwave_sync_interval" in data:
|
||||
self.zwave_sync_interval = int(data["zwave_sync_interval"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -86,6 +221,22 @@ 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,
|
||||
# Proxmox: only the auto-sync activation is persisted. Connection
|
||||
# config (host, port, token, verify_tls) is env-only and must never
|
||||
# be written to disk — that is the single source of truth.
|
||||
"proxmox_sync_enabled": self.proxmox_sync_enabled,
|
||||
"proxmox_sync_interval": self.proxmox_sync_interval,
|
||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
||||
# connection config (host/port/credentials/topic/tls) is env-only.
|
||||
"zigbee_sync_enabled": self.zigbee_sync_enabled,
|
||||
"zigbee_sync_interval": self.zigbee_sync_interval,
|
||||
"zwave_sync_enabled": self.zwave_sync_enabled,
|
||||
"zwave_sync_interval": self.zwave_sync_interval,
|
||||
}))
|
||||
|
||||
|
||||
|
||||
+347
-24
@@ -1,28 +1,45 @@
|
||||
"""APScheduler setup for background scan and status check jobs."""
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.db.models import InventoryDevice, Node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.schemas.zigbee import ZigbeeImportRequest
|
||||
from app.schemas.zwave import ZwaveImportRequest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||
|
||||
|
||||
async def _check_single_node(
|
||||
node_id: str,
|
||||
async def _nodes_by_device(db: AsyncSession) -> dict[str, list[str]]:
|
||||
"""Map each device id to the nodes drawing it, across every canvas."""
|
||||
rows = (await db.execute(select(Node.id, Node.device_id).where(Node.device_id.is_not(None)))).all()
|
||||
out: dict[str, list[str]] = {}
|
||||
for node_id, device_id in rows:
|
||||
out.setdefault(device_id, []).append(node_id)
|
||||
return out
|
||||
|
||||
|
||||
async def _check_single_device(
|
||||
device_id: str,
|
||||
node_ids: list[str],
|
||||
check_method: str,
|
||||
check_target: str | None,
|
||||
ip: str | None,
|
||||
) -> tuple[str, dict[str, object] | None]:
|
||||
"""Run a single node check; returns (node_id, result_or_None).
|
||||
"""Run a single device check; returns (device_id, result_or_None).
|
||||
|
||||
Accepts plain scalars — not an ORM object — so there is no risk of
|
||||
DetachedInstanceError when the originating session has already closed.
|
||||
@@ -31,48 +48,246 @@ async def _check_single_node(
|
||||
|
||||
try:
|
||||
check_result = await check_node(check_method, check_target, ip)
|
||||
raw_ms = check_result["response_time_ms"]
|
||||
response_ms = raw_ms if isinstance(raw_ms, int) else None
|
||||
now = datetime.now(timezone.utc)
|
||||
async with AsyncSessionLocal() as db:
|
||||
n = await db.get(Node, node_id)
|
||||
if n:
|
||||
n.status = check_result["status"]
|
||||
n.response_time_ms = check_result["response_time_ms"]
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device:
|
||||
device.status_live = str(check_result["status"])
|
||||
device.response_time_ms = response_ms
|
||||
if check_result["status"] == "online":
|
||||
n.last_seen = now
|
||||
device.last_seen = now
|
||||
await db.commit()
|
||||
await broadcast_status(
|
||||
node_id=node_id,
|
||||
status=check_result["status"],
|
||||
device_id=device_id,
|
||||
node_ids=node_ids,
|
||||
status=str(check_result["status"]),
|
||||
checked_at=now.isoformat(),
|
||||
response_time_ms=check_result["response_time_ms"],
|
||||
response_time_ms=response_ms,
|
||||
)
|
||||
return node_id, check_result
|
||||
return device_id, check_result
|
||||
except Exception as exc:
|
||||
logger.error("Status check failed for node %s: %s", node_id, exc)
|
||||
return node_id, None
|
||||
logger.error("Status check failed for device %s: %s", device_id, exc)
|
||||
return device_id, None
|
||||
|
||||
|
||||
async def _run_status_checks() -> None:
|
||||
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
||||
"""Check every device once and broadcast the result to every canvas.
|
||||
|
||||
Device-scoped, not node-scoped: a host drawn on three canvases used to be
|
||||
pinged three times and could report three different states. Hidden devices
|
||||
are skipped — hiding one is how a user says "stop showing me this".
|
||||
|
||||
A device with no node at all is still checked, deliberately. The inventory
|
||||
row owns the device and shows its live status in the list and the detail
|
||||
modal, so a rack mount or an inventory-only entry reports state without
|
||||
being drawn anywhere — and deleting a node no longer silently stops
|
||||
monitoring the host. `hide` (or clearing the check method) is what ends the
|
||||
checks; the broadcast simply carries an empty `node_ids`.
|
||||
"""
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
devices = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.check_method.is_not(None),
|
||||
InventoryDevice.check_method != "",
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
node_map = await _nodes_by_device(db)
|
||||
# Extract scalars while the session is open to avoid DetachedInstanceError
|
||||
checkable = [
|
||||
(n.id, n.check_method, n.check_target, n.ip)
|
||||
for n in nodes
|
||||
if n.check_method
|
||||
(d.id, node_map.get(d.id, []), d.check_method or "ping", d.check_target, d.ip)
|
||||
for d in devices
|
||||
]
|
||||
|
||||
if not checkable:
|
||||
return
|
||||
|
||||
await asyncio.gather(*[
|
||||
_check_single_node(node_id, method, target, ip)
|
||||
for node_id, method, target, ip in checkable
|
||||
_check_single_device(device_id, node_ids, method, target, ip)
|
||||
for device_id, node_ids, method, target, ip in checkable
|
||||
])
|
||||
|
||||
|
||||
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 device and broadcast per-service results.
|
||||
|
||||
Device-scoped for the same reason as the status check: the services belong
|
||||
to the device, so one pass serves every canvas showing it.
|
||||
"""
|
||||
if not settings.service_check_enabled:
|
||||
return
|
||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
node_map = await _nodes_by_device(db)
|
||||
checkable = [
|
||||
(d.id, node_map.get(d.id, []), _node_host(d.ip, d.hostname), list(d.services or []))
|
||||
for d in devices
|
||||
if d.services
|
||||
]
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
for device_id, node_ids, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(
|
||||
device_id=device_id, node_ids=node_ids, services=statuses, checked_at=now
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Service checks failed for device %s: %s", device_id, exc)
|
||||
|
||||
|
||||
async def _run_proxmox_sync() -> None:
|
||||
"""Fetch the Proxmox inventory and upsert it into pending (auto-sync).
|
||||
|
||||
Records a ScanRun (kind=proxmox) so the scheduled sync shows in Scan
|
||||
history, exactly like the manual /sync-now and /import-pending paths.
|
||||
"""
|
||||
if not settings.proxmox_sync_enabled:
|
||||
return
|
||||
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
|
||||
logger.warning("Proxmox auto-sync enabled but host/token not configured — skipping")
|
||||
return
|
||||
# Lazy import to avoid a circular import at module load.
|
||||
from app.api.routes.proxmox import _background_proxmox_import
|
||||
from app.db.models import ScanRun
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="proxmox",
|
||||
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
run_id = run.id
|
||||
|
||||
# Shares the manual-sync flow: fetch + persist + mark the run done/error +
|
||||
# broadcast the inventory-reload signal.
|
||||
await _background_proxmox_import(
|
||||
run_id,
|
||||
settings.proxmox_host,
|
||||
settings.proxmox_port,
|
||||
settings.proxmox_token_id,
|
||||
settings.proxmox_token_secret,
|
||||
settings.proxmox_verify_tls,
|
||||
)
|
||||
|
||||
|
||||
async def _run_mesh_sync(kind: str) -> None:
|
||||
"""Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave).
|
||||
|
||||
Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan
|
||||
history, then delegates to the exact same background import the manual
|
||||
/sync-now and /import-pending paths use. Connection config comes from the
|
||||
server env only (credentials never leave it).
|
||||
"""
|
||||
from app.db.models import ScanRun
|
||||
|
||||
payload: ZigbeeImportRequest | ZwaveImportRequest
|
||||
background: Callable[[str, Any], Awaitable[None]]
|
||||
|
||||
if kind == "zigbee":
|
||||
if not settings.zigbee_sync_enabled:
|
||||
return
|
||||
if not settings.zigbee_mqtt_host:
|
||||
logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping")
|
||||
return
|
||||
from app.api.routes.zigbee import _background_zigbee_import
|
||||
from app.api.routes.zigbee import env_import_request as _zigbee_env_request
|
||||
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
|
||||
payload = _zigbee_env_request()
|
||||
background = _background_zigbee_import
|
||||
else:
|
||||
if not settings.zwave_sync_enabled:
|
||||
return
|
||||
if not settings.zwave_mqtt_host:
|
||||
logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping")
|
||||
return
|
||||
from app.api.routes.zwave import _background_zwave_import
|
||||
from app.api.routes.zwave import env_import_request as _zwave_env_request
|
||||
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
|
||||
payload = _zwave_env_request()
|
||||
background = _background_zwave_import
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"])
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
run_id = run.id
|
||||
|
||||
await background(run_id, payload)
|
||||
|
||||
|
||||
async def _run_zigbee_sync() -> None:
|
||||
await _run_mesh_sync("zigbee")
|
||||
|
||||
|
||||
async def _run_zwave_sync() -> None:
|
||||
await _run_mesh_sync("zwave")
|
||||
|
||||
|
||||
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 _add_proxmox_sync_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_proxmox_sync,
|
||||
"interval",
|
||||
seconds=settings.proxmox_sync_interval,
|
||||
id="proxmox_sync",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def _add_zigbee_sync_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_zigbee_sync,
|
||||
"interval",
|
||||
seconds=settings.zigbee_sync_interval,
|
||||
id="zigbee_sync",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def _add_zwave_sync_job() -> None:
|
||||
scheduler.add_job(
|
||||
_run_zwave_sync,
|
||||
"interval",
|
||||
seconds=settings.zwave_sync_interval,
|
||||
id="zwave_sync",
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
global scheduler
|
||||
if scheduler.running:
|
||||
@@ -89,6 +304,14 @@ def start_scheduler() -> None:
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
if settings.service_check_enabled:
|
||||
_add_service_check_job()
|
||||
if settings.proxmox_sync_enabled:
|
||||
_add_proxmox_sync_job()
|
||||
if settings.zigbee_sync_enabled:
|
||||
_add_zigbee_sync_job()
|
||||
if settings.zwave_sync_enabled:
|
||||
_add_zwave_sync_job()
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||
|
||||
@@ -104,6 +327,106 @@ 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 reschedule_proxmox_sync(interval_seconds: int) -> None:
|
||||
"""Update the Proxmox auto-sync interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 300:
|
||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("proxmox_sync"):
|
||||
scheduler.reschedule_job("proxmox_sync", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Proxmox auto-sync rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_proxmox_sync_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the Proxmox auto-sync job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("proxmox_sync")
|
||||
if enabled and not job:
|
||||
_add_proxmox_sync_job()
|
||||
logger.info("Proxmox auto-sync enabled — every %ds", settings.proxmox_sync_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("proxmox_sync")
|
||||
logger.info("Proxmox auto-sync disabled")
|
||||
|
||||
|
||||
def reschedule_zigbee_sync(interval_seconds: int) -> None:
|
||||
"""Update the Zigbee auto-sync interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 300:
|
||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("zigbee_sync"):
|
||||
scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_zigbee_sync_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the Zigbee auto-sync job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("zigbee_sync")
|
||||
if enabled and not job:
|
||||
_add_zigbee_sync_job()
|
||||
logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("zigbee_sync")
|
||||
logger.info("Zigbee auto-sync disabled")
|
||||
|
||||
|
||||
def reschedule_zwave_sync(interval_seconds: int) -> None:
|
||||
"""Update the Z-Wave auto-sync interval on the running scheduler (if enabled)."""
|
||||
if interval_seconds < 300:
|
||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
||||
if not scheduler.running:
|
||||
logger.warning("Scheduler not running, skipping reschedule")
|
||||
return
|
||||
if scheduler.get_job("zwave_sync"):
|
||||
scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds)
|
||||
logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds)
|
||||
|
||||
|
||||
def set_zwave_sync_enabled(enabled: bool) -> None:
|
||||
"""Add or remove the Z-Wave auto-sync job on the running scheduler."""
|
||||
if not scheduler.running:
|
||||
return
|
||||
job = scheduler.get_job("zwave_sync")
|
||||
if enabled and not job:
|
||||
_add_zwave_sync_job()
|
||||
logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval)
|
||||
elif not enabled and job:
|
||||
scheduler.remove_job("zwave_sync")
|
||||
logger.info("Z-Wave auto-sync disabled")
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import hmac
|
||||
import secrets
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
import jwt
|
||||
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
|
||||
from fastapi import Request, Response, status
|
||||
from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OIDCSession:
|
||||
subject: str
|
||||
issuer: str
|
||||
display_name: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
if not plain or not hashed:
|
||||
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
|
||||
payload = {"sub": subject, "exp": expire}
|
||||
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_token(token: str) -> str | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
|
||||
return None
|
||||
sub = payload.get("sub")
|
||||
return str(sub) if sub is not None else None
|
||||
except JWTError:
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
|
||||
subject = userinfo.get("sub")
|
||||
issuer = userinfo.get("iss")
|
||||
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
|
||||
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
|
||||
|
||||
display_name = next(
|
||||
(
|
||||
value
|
||||
for value in (
|
||||
userinfo.get("preferred_username"),
|
||||
userinfo.get("name"),
|
||||
userinfo.get("email"),
|
||||
subject,
|
||||
)
|
||||
if isinstance(value, str) and value
|
||||
),
|
||||
subject,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"oidc_issuer": issuer,
|
||||
"name": display_name,
|
||||
"csrf": secrets.token_urlsafe(32),
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
|
||||
"token_use": OIDC_SESSION_TOKEN_USE,
|
||||
}
|
||||
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
|
||||
|
||||
|
||||
def decode_oidc_session_token(token: str) -> OIDCSession | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
|
||||
return None
|
||||
subject = payload.get("sub")
|
||||
issuer = payload.get("oidc_issuer")
|
||||
display_name = payload.get("name")
|
||||
csrf_token = payload.get("csrf")
|
||||
if not isinstance(subject, str) or not subject:
|
||||
return None
|
||||
if not isinstance(issuer, str) or not issuer:
|
||||
return None
|
||||
if not isinstance(display_name, str) or not display_name:
|
||||
return None
|
||||
if not isinstance(csrf_token, str) or not csrf_token:
|
||||
return None
|
||||
return OIDCSession(
|
||||
subject=subject,
|
||||
issuer=issuer,
|
||||
display_name=display_name,
|
||||
csrf_token=csrf_token,
|
||||
)
|
||||
except InvalidTokenError:
|
||||
return None
|
||||
|
||||
|
||||
def oidc_session_cookie_name() -> str:
|
||||
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
|
||||
|
||||
|
||||
def set_oidc_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
value=token,
|
||||
max_age=settings.oidc_session_expire_minutes * 60,
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def clear_oidc_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
key=oidc_session_cookie_name(),
|
||||
secure=settings.oidc_cookie_secure,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def get_oidc_session(request: Request) -> OIDCSession | None:
|
||||
token = request.cookies.get(oidc_session_cookie_name())
|
||||
return decode_oidc_session_token(token) if token else None
|
||||
|
||||
|
||||
def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
# Explicit non-cookie credentials take precedence in get_current_user,
|
||||
# so their requests keep the existing CSRF-free API behavior.
|
||||
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
|
||||
return await call_next(request)
|
||||
|
||||
oidc_session = get_oidc_session(request)
|
||||
if oidc_session is None:
|
||||
return await call_next(request)
|
||||
|
||||
csrf_token = request.headers.get("X-Homelable-CSRF", "")
|
||||
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
|
||||
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "CSRF validation failed"},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _build_oidc_client(
|
||||
discovery_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
scopes: str,
|
||||
) -> StarletteOAuth2App:
|
||||
oauth = OAuth()
|
||||
client = oauth.register(
|
||||
name="oidc",
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
server_metadata_url=discovery_url,
|
||||
client_kwargs={
|
||||
"scope": scopes,
|
||||
"code_challenge_method": "S256",
|
||||
},
|
||||
)
|
||||
if client is None:
|
||||
raise RuntimeError("Failed to register OIDC client")
|
||||
return client
|
||||
|
||||
|
||||
def get_oidc_client() -> StarletteOAuth2App:
|
||||
return _build_oidc_client(
|
||||
settings.oidc_discovery_url,
|
||||
settings.oidc_client_id,
|
||||
settings.oidc_client_secret,
|
||||
settings.oidc_scopes,
|
||||
)
|
||||
|
||||
@@ -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,74 @@
|
||||
{"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": "[Pp]i[Kk]VM", "service_name": "PiKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "TinyPilot", "service_name": "TinyPilot", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "JetKVM", "service_name": "JetKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "NanoKVM", "service_name": "NanoKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
|
||||
|
||||
{"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"}
|
||||
]
|
||||
|
||||
+374
-22
@@ -5,8 +5,9 @@ import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -60,9 +61,67 @@ def _backup_db() -> None:
|
||||
logger.warning("Could not create DB backup at %s", backup_path)
|
||||
|
||||
|
||||
# Tables renamed in 3.2.0. "Pending devices" was the scanner's word for a queue
|
||||
# of finds awaiting approval; the same rows outlive approval, are edited by hand
|
||||
# and are what a rack mounts, so the product calls them the Device Inventory.
|
||||
# Only the names moved — every column, every row and every route is unchanged.
|
||||
_RENAMED_TABLES: list[tuple[str, str]] = [
|
||||
("pending_devices", "device_inventory"),
|
||||
("pending_device_links", "device_inventory_links"),
|
||||
]
|
||||
|
||||
|
||||
async def _rename_legacy_tables(conn: AsyncConnection) -> None:
|
||||
"""Rename the pre-3.2.0 inventory tables. Must run before `create_all`.
|
||||
|
||||
Order matters: `create_all` would otherwise create an empty
|
||||
`device_inventory` beside the populated `pending_devices`, and every device
|
||||
the user ever scanned would look lost.
|
||||
|
||||
Foreign keys are switched on for the rename so SQLite rewrites the
|
||||
``REFERENCES pending_devices`` clause in `rack_devices` too; enforcement is
|
||||
off at runtime, so a missed rewrite costs nothing today, but it would leave
|
||||
the schema naming a table that no longer exists.
|
||||
"""
|
||||
for old, new in _RENAMED_TABLES:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?)",
|
||||
(old, new),
|
||||
)
|
||||
).fetchall()
|
||||
names = {r[0] for r in rows}
|
||||
if old not in names:
|
||||
continue # Fresh install, or already migrated.
|
||||
if new in names:
|
||||
# A start that ran `create_all` before this migration existed left an
|
||||
# empty new table beside the populated old one, and the app read the
|
||||
# empty one. Clear it out of the way rather than stranding the data;
|
||||
# a new table with rows in it means the rename already happened and
|
||||
# something re-created the old one, which is not ours to resolve.
|
||||
count = (await conn.exec_driver_sql(f"SELECT COUNT(*) FROM {new}")).scalar()
|
||||
if count:
|
||||
logger.warning(
|
||||
"Both %s and %s hold rows; leaving them alone. Merge them by hand.", old, new
|
||||
)
|
||||
continue
|
||||
logger.info("Dropping the empty %s left by an earlier start", new)
|
||||
await _try_migrate(conn, f"DROP TABLE {new}", label=f"{new}.drop_empty")
|
||||
logger.info("Migrating table %s -> %s", old, new)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
await _try_migrate(conn, f"ALTER TABLE {old} RENAME TO {new}", label=f"{new}.rename")
|
||||
# The index rides the rename under its old name; the model asks for the new
|
||||
# one, so drop the stale duplicate rather than carrying both.
|
||||
await _try_migrate(
|
||||
conn, "DROP INDEX IF EXISTS ix_pending_devices_ieee_address", label="device_inventory.index",
|
||||
)
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
_backup_db()
|
||||
async with engine.begin() as conn:
|
||||
await _rename_legacy_tables(conn)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Add columns introduced after initial schema (idempotent)
|
||||
with suppress(OperationalError):
|
||||
@@ -81,6 +140,14 @@ async def init_db() -> None:
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start TEXT NOT NULL DEFAULT 'none'")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN line_style TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN width_mult REAL")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||
with suppress(OperationalError):
|
||||
@@ -91,6 +158,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):
|
||||
@@ -98,7 +167,17 @@ async def init_db() -> None:
|
||||
with suppress(OperationalError):
|
||||
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")
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_source TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN properties JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE device_inventory SET properties = '[]' WHERE properties IS NULL")
|
||||
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) ---
|
||||
@@ -108,32 +187,32 @@ async def init_db() -> None:
|
||||
"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"),
|
||||
("device_inventory.ieee_address", "ALTER TABLE device_inventory 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)",
|
||||
"device_inventory.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(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"),
|
||||
("device_inventory.friendly_name", "ALTER TABLE device_inventory ADD COLUMN friendly_name TEXT"),
|
||||
("device_inventory.device_subtype", "ALTER TABLE device_inventory ADD COLUMN device_subtype TEXT"),
|
||||
("device_inventory.model", "ALTER TABLE device_inventory ADD COLUMN model TEXT"),
|
||||
("device_inventory.vendor", "ALTER TABLE device_inventory ADD COLUMN vendor TEXT"),
|
||||
("device_inventory.lqi", "ALTER TABLE device_inventory 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).
|
||||
# Drop NOT NULL on device_inventory.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)")
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(device_inventory)")
|
||||
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")
|
||||
logger.info("Migrating device_inventory: 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 ("
|
||||
"CREATE TABLE device_inventory_new ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"ip VARCHAR,"
|
||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||
@@ -146,29 +225,30 @@ async def init_db() -> None:
|
||||
"model VARCHAR,"
|
||||
"vendor VARCHAR,"
|
||||
"lqi INTEGER,"
|
||||
"properties JSON,"
|
||||
"discovered_at DATETIME"
|
||||
")"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO pending_devices_new "
|
||||
"INSERT INTO device_inventory_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"
|
||||
"model, vendor, lqi, discovered_at FROM device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
||||
await conn.exec_driver_sql("DROP TABLE device_inventory")
|
||||
await conn.exec_driver_sql(
|
||||
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
||||
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)"
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(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)
|
||||
logger.warning("device_inventory ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
@@ -265,6 +345,22 @@ async def init_db() -> None:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
# Rack cables gained editable annotations: a label the user can print on
|
||||
# the canvas, plus the same free-form property records nodes carry.
|
||||
rack_cable_migrations: list[tuple[str, str]] = [
|
||||
(
|
||||
"rack_cables.label_visible",
|
||||
"ALTER TABLE rack_cables ADD COLUMN label_visible BOOLEAN NOT NULL DEFAULT 0",
|
||||
),
|
||||
("rack_cables.properties", "ALTER TABLE rack_cables ADD COLUMN properties JSON"),
|
||||
]
|
||||
for label, sql in rack_cable_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE rack_cables SET properties = '[]' WHERE properties IS NULL"
|
||||
)
|
||||
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
@@ -291,12 +387,268 @@ async def init_db() -> None:
|
||||
"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")
|
||||
with suppress(OperationalError):
|
||||
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
||||
await conn.exec_driver_sql(sql)
|
||||
# Multi-source discovery tags: a device found by both an IP scan and a
|
||||
# Proxmox import carries every source. Backfill from the legacy single
|
||||
# discovery_source so existing rows show under their filter.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_sources JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE device_inventory SET discovery_sources = json_array(discovery_source) "
|
||||
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
|
||||
)
|
||||
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
|
||||
# but a real IP — treat them as an ARP scan so they keep the IP tag.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE device_inventory SET discovery_sources = json_array('arp') "
|
||||
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE device_inventory SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
|
||||
)
|
||||
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
|
||||
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE device_inventory SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
)
|
||||
# 3.3.0 — the inventory row owns the device facts, the canvas node only
|
||||
# owns how it is drawn. These columns receive what used to live solely on
|
||||
# `nodes`. Additive here; `nodes` keeps its copies until the backfill has
|
||||
# run and been verified.
|
||||
inventory_device_migrations: list[tuple[str, str]] = [
|
||||
("device_inventory.label", "ALTER TABLE device_inventory ADD COLUMN label TEXT"),
|
||||
("device_inventory.type", "ALTER TABLE device_inventory ADD COLUMN type TEXT"),
|
||||
("device_inventory.notes", "ALTER TABLE device_inventory ADD COLUMN notes TEXT"),
|
||||
("device_inventory.cpu_count", "ALTER TABLE device_inventory ADD COLUMN cpu_count INTEGER"),
|
||||
("device_inventory.cpu_model", "ALTER TABLE device_inventory ADD COLUMN cpu_model TEXT"),
|
||||
("device_inventory.ram_gb", "ALTER TABLE device_inventory ADD COLUMN ram_gb FLOAT"),
|
||||
("device_inventory.disk_gb", "ALTER TABLE device_inventory ADD COLUMN disk_gb FLOAT"),
|
||||
(
|
||||
"device_inventory.show_hardware",
|
||||
"ALTER TABLE device_inventory ADD COLUMN show_hardware BOOLEAN DEFAULT 0",
|
||||
),
|
||||
("device_inventory.check_method", "ALTER TABLE device_inventory ADD COLUMN check_method TEXT"),
|
||||
("device_inventory.check_target", "ALTER TABLE device_inventory ADD COLUMN check_target TEXT"),
|
||||
(
|
||||
"device_inventory.status_live",
|
||||
"ALTER TABLE device_inventory ADD COLUMN status_live TEXT DEFAULT 'unknown'",
|
||||
),
|
||||
("device_inventory.last_seen", "ALTER TABLE device_inventory ADD COLUMN last_seen DATETIME"),
|
||||
("device_inventory.last_scan", "ALTER TABLE device_inventory ADD COLUMN last_scan DATETIME"),
|
||||
(
|
||||
"device_inventory.response_time_ms",
|
||||
"ALTER TABLE device_inventory ADD COLUMN response_time_ms INTEGER",
|
||||
),
|
||||
("device_inventory.updated_at", "ALTER TABLE device_inventory ADD COLUMN updated_at DATETIME"),
|
||||
]
|
||||
for label, sql in inventory_device_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
# Backfill the columns that carry a non-NULL default in the model, so a
|
||||
# legacy row round-trips through Pydantic without tripping the validators.
|
||||
# The link itself: a node points at the inventory row it draws.
|
||||
for label, sql in (
|
||||
("nodes.device_id", "ALTER TABLE nodes ADD COLUMN device_id TEXT"),
|
||||
(
|
||||
"nodes.device_id.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)",
|
||||
),
|
||||
):
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
for label, sql in (
|
||||
(
|
||||
"device_inventory.status_live.backfill",
|
||||
"UPDATE device_inventory SET status_live = 'unknown' WHERE status_live IS NULL",
|
||||
),
|
||||
(
|
||||
"device_inventory.show_hardware.backfill",
|
||||
"UPDATE device_inventory SET show_hardware = 0 WHERE show_hardware IS NULL",
|
||||
),
|
||||
(
|
||||
"device_inventory.updated_at.backfill",
|
||||
"UPDATE device_inventory SET updated_at = discovered_at WHERE updated_at IS NULL",
|
||||
),
|
||||
):
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
|
||||
await _backfill_node_devices()
|
||||
await _drop_legacy_node_columns()
|
||||
|
||||
|
||||
|
||||
# Columns `nodes` carried before 3.3.0, when a node owned the device facts. They
|
||||
# belong to `device_inventory` now; the backfill above copies them across, and
|
||||
# this rebuild removes them.
|
||||
_LEGACY_NODE_COLUMNS = (
|
||||
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
|
||||
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
|
||||
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
|
||||
"response_time_ms",
|
||||
)
|
||||
|
||||
# What a node keeps: how the device is drawn on one canvas.
|
||||
_NODE_COLUMNS_SQL = (
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"type VARCHAR NOT NULL,"
|
||||
"label VARCHAR NOT NULL,"
|
||||
"design_id VARCHAR REFERENCES designs(id) ON DELETE SET NULL,"
|
||||
"device_id VARCHAR REFERENCES device_inventory(id) ON DELETE SET NULL,"
|
||||
"pos_x FLOAT,"
|
||||
"pos_y FLOAT,"
|
||||
"parent_id VARCHAR REFERENCES nodes(id) ON DELETE CASCADE,"
|
||||
"container_mode BOOLEAN,"
|
||||
"custom_colors JSON,"
|
||||
"custom_icon VARCHAR,"
|
||||
"show_port_numbers BOOLEAN,"
|
||||
"width FLOAT,"
|
||||
"height FLOAT,"
|
||||
"bottom_handles INTEGER,"
|
||||
"top_handles INTEGER,"
|
||||
"left_handles INTEGER,"
|
||||
"right_handles INTEGER,"
|
||||
"created_at DATETIME,"
|
||||
"updated_at DATETIME"
|
||||
)
|
||||
|
||||
_NODE_KEPT = (
|
||||
"id, type, label, design_id, device_id, pos_x, pos_y, parent_id, container_mode, "
|
||||
"custom_colors, custom_icon, show_port_numbers, width, height, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at"
|
||||
)
|
||||
|
||||
|
||||
async def _relax_legacy_node_columns(conn: AsyncConnection, info: list[Any]) -> None:
|
||||
"""Make the retained legacy `nodes` columns nullable — SQLite table rebuild.
|
||||
|
||||
The 3.2.0 schema declares `status`, `services`, `properties` and
|
||||
`show_hardware` NOT NULL with no server-side default. The 3.3.0 model no
|
||||
longer maps them, so every INSERT omits them and SQLite rejects the row —
|
||||
approving a device, creating a node, importing a canvas all fail with
|
||||
``NOT NULL constraint failed: nodes.status``. Dropping the columns is the
|
||||
real fix, but it waits on a complete backfill; until then they have to stop
|
||||
blocking writes. Values are preserved: only the constraint goes.
|
||||
"""
|
||||
kept = [row for row in info if row[1] in _LEGACY_NODE_COLUMNS]
|
||||
if not any(row[3] for row in kept): # PRAGMA `notnull`
|
||||
return # Already relaxed, or never constrained.
|
||||
|
||||
logger.info("Relaxing NOT NULL on the retained legacy node columns")
|
||||
legacy_defs = ",".join(f"{row[1]} {row[2] or 'VARCHAR'}" for row in kept)
|
||||
legacy_names = ", ".join(row[1] for row in kept)
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=f"{_NODE_COLUMNS_SQL},{legacy_defs}",
|
||||
copied=f"{_NODE_KEPT}, {legacy_names}",
|
||||
what="nodes legacy-column relax",
|
||||
)
|
||||
|
||||
|
||||
async def _rebuild_nodes(conn: AsyncConnection, *, columns_sql: str, copied: str, what: str) -> None:
|
||||
"""Recreate `nodes` with a new column list — SQLite cannot alter constraints.
|
||||
|
||||
Never fatal: a rebuild that fails leaves the table it could not replace, and
|
||||
the boot carries on. Foreign keys go off for the swap, because `edges` and
|
||||
`rack_devices` point at `nodes`, and back on in every case — the pragma is
|
||||
per connection and this one returns to the pool.
|
||||
"""
|
||||
try:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
# A previous attempt that failed after the create would block this one.
|
||||
await conn.exec_driver_sql("DROP TABLE IF EXISTS nodes_new")
|
||||
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({columns_sql})")
|
||||
await conn.exec_driver_sql(f"INSERT INTO nodes_new ({copied}) SELECT {copied} FROM nodes")
|
||||
await conn.exec_driver_sql("DROP TABLE nodes")
|
||||
await conn.exec_driver_sql("ALTER TABLE nodes_new RENAME TO nodes")
|
||||
await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)")
|
||||
except (OperationalError, IntegrityError) as exc:
|
||||
logger.warning("%s failed: %s", what, exc)
|
||||
finally:
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
|
||||
|
||||
async def _drop_legacy_node_columns() -> None:
|
||||
"""Remove the device columns from `nodes` (3.3.0) — SQLite table rebuild.
|
||||
|
||||
Runs only after the backfill has linked every device node to its inventory
|
||||
row. If any non-furniture node is still unlinked the drop is skipped and
|
||||
logged: the columns are the only remaining copy of that node's facts, and
|
||||
losing them is not recoverable. The skip then relaxes their NOT NULL instead,
|
||||
because the 3.3.0 model no longer writes them and the database must stay
|
||||
insertable while the backfill is retried on later boots.
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
present = {row[1] for row in info}
|
||||
if not (present & set(_LEGACY_NODE_COLUMNS)):
|
||||
return # Already migrated.
|
||||
|
||||
unlinked = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT COUNT(*) FROM nodes WHERE device_id IS NULL "
|
||||
"AND type NOT IN ('group', 'groupRect', 'text')"
|
||||
)
|
||||
).scalar()
|
||||
if unlinked:
|
||||
logger.warning(
|
||||
"Keeping the legacy node columns: %d node(s) have no inventory row. "
|
||||
"The backfill must link every device node before they can be dropped.",
|
||||
unlinked,
|
||||
)
|
||||
await _relax_legacy_node_columns(conn, list(info))
|
||||
return
|
||||
|
||||
logger.info("Migrating nodes: the device columns move to device_inventory")
|
||||
await _rebuild_nodes(
|
||||
conn,
|
||||
columns_sql=_NODE_COLUMNS_SQL,
|
||||
copied=_NODE_KEPT,
|
||||
what="nodes device-column drop",
|
||||
)
|
||||
|
||||
|
||||
async def _backfill_node_devices() -> None:
|
||||
"""Link existing canvas nodes to their Device Inventory rows (3.3.0).
|
||||
|
||||
Runs outside the DDL connection because the merge is Python-side, not SQL.
|
||||
Self-limiting: it only looks at nodes with no ``device_id``, so a second boot
|
||||
finds nothing to do. Never fatal — a failure here leaves the canvas working
|
||||
off its own columns, which still carry the data at this point.
|
||||
"""
|
||||
# Imported here: app.services imports app.db.models, which imports this module.
|
||||
from app.services.inventory_sync import backfill_node_devices
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
stats = await backfill_node_devices(session)
|
||||
if stats["linked"]:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Inventory backfill: linked %d node(s) — %d device(s) created, %d merged",
|
||||
stats["linked"], stats["created"], stats["merged"],
|
||||
)
|
||||
if stats.get("skipped"):
|
||||
logger.warning(
|
||||
"Inventory backfill: %d node(s) could not be linked; they keep their "
|
||||
"legacy columns and are retried on the next start.",
|
||||
stats["skipped"],
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Inventory backfill failed: %s", exc)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
+175
-23
@@ -28,39 +28,40 @@ class Design(Base):
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""How a device is drawn on one canvas.
|
||||
|
||||
A node owns presentation only — position, size, colours, icon, handles,
|
||||
nesting. What the device *is* (addresses, services, properties, notes,
|
||||
hardware, check method, live status) belongs to the `device_inventory` row
|
||||
named by `device_id`, so one device reads the same on every canvas. The API
|
||||
still reports both together: see `services/inventory_sync.hydrated_node`.
|
||||
"""
|
||||
|
||||
__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)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
check_method: Mapped[str | None] = mapped_column(String)
|
||||
check_target: Mapped[str | None] = mapped_column(String)
|
||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
# The Device Inventory row this node draws. NULL for canvas furniture
|
||||
# (group / groupRect / text), which represents nothing physical. Deleting a
|
||||
# node never deletes the device — the inventory outlives every canvas.
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), index=True, nullable=True
|
||||
)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
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)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
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))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
top_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
left_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||
right_handles: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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")
|
||||
@@ -80,7 +81,11 @@ class Edge(Base):
|
||||
speed: Mapped[str | None] = mapped_column(String)
|
||||
custom_color: Mapped[str | None] = mapped_column(String)
|
||||
path_style: Mapped[str | None] = mapped_column(String)
|
||||
line_style: Mapped[str | None] = mapped_column(String)
|
||||
width_mult: Mapped[float | None] = mapped_column(Float)
|
||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
||||
marker_end: 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)
|
||||
@@ -96,8 +101,109 @@ class CanvasState(Base):
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingDevice(Base):
|
||||
__tablename__ = "pending_devices"
|
||||
class Rack(Base):
|
||||
"""A physical rack on a `design_type='rack'` canvas.
|
||||
|
||||
Racks live per design, like nodes and edges. Geometry is expressed in rack
|
||||
units: `u_height` is the number of mountable U, and a device's `u_start` is
|
||||
always counted from the bottom rail — `numbering` only changes the printed
|
||||
labels.
|
||||
"""
|
||||
|
||||
__tablename__ = "racks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=42)
|
||||
# "19" or "10" (inches). Drives the drawn inner width.
|
||||
width_standard: Mapped[str] = mapped_column(String, default="19")
|
||||
# "bottom-up" or "top-down" — printed U labels only, never storage order.
|
||||
numbering: Mapped[str] = mapped_column(String, default="bottom-up")
|
||||
location: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Frame/rail/interior colours + showNumbers/enclosed flags.
|
||||
style: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
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 RackDevice(Base):
|
||||
"""A piece of gear mounted in a rack.
|
||||
|
||||
Two independent, both-optional links back to the rest of the app:
|
||||
|
||||
* `device_id` — the Device Inventory entry (`device_inventory`). This is the
|
||||
primary link: inventory rows survive approval *and* node deletion, so
|
||||
unracking or deleting a canvas node never removes the inventory entry.
|
||||
* `node_id` — the logical-canvas node, when one exists. Only used to resolve
|
||||
live status and to seed cables from the network design's edges.
|
||||
|
||||
Both are ``SET NULL`` on delete and `label` is denormalized, so a rack keeps
|
||||
rendering after an inventory purge.
|
||||
"""
|
||||
|
||||
__tablename__ = "rack_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
rack_id: Mapped[str] = mapped_column(String, ForeignKey("racks.id", ondelete="CASCADE"), nullable=False)
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
node_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 1-based, counted from the bottom rail.
|
||||
u_start: Mapped[int] = mapped_column(Integer, default=1)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=1)
|
||||
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
|
||||
col_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
col_span: Mapped[int] = mapped_column(Integer, default=12)
|
||||
faceplate_id: Mapped[str] = mapped_column(String, nullable=False, default="blank-1u")
|
||||
color: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
# [{id, label, type, x, y}] — positions are unit coordinates on the plate.
|
||||
ports: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
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 RackCable(Base):
|
||||
"""A patch between two rack device ports.
|
||||
|
||||
Not an `Edge`: rack cables are port-to-port and may cross racks, so they are
|
||||
their own relation rather than a canvas edge.
|
||||
"""
|
||||
|
||||
__tablename__ = "rack_cables"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
from_device_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
from_port_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
to_device_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("rack_devices.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
to_port_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||
color: Mapped[str] = mapped_column(String, default="#39d353")
|
||||
label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Print the label next to the run on the canvas.
|
||||
label_visible: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# [{key, value, icon, visible}] — same records nodes carry; the visible ones
|
||||
# are drawn beside the cable.
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class InventoryDevice(Base):
|
||||
__tablename__ = "device_inventory"
|
||||
# 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 | None] = mapped_column(String, nullable=True)
|
||||
@@ -107,26 +213,72 @@ class PendingDevice(Base):
|
||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="pending")
|
||||
# Origin/primary source (first discovery): "arp"/"mdns"/"zigbee"/"zwave"/
|
||||
# "proxmox". Kept for back-compat; `discovery_sources` is the full set.
|
||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
||||
# All sources that have observed this device. A device found by both an IP
|
||||
# scan and a Proxmox import carries e.g. ["arp", "proxmox"] and shows under
|
||||
# both inventory filters. Source of truth for the frontend source badges.
|
||||
discovery_sources: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
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)
|
||||
# Display properties carried from discovery (e.g. Proxmox specs: CPU/RAM/Disk,
|
||||
# VMID). Generic NodeProperty shape {key,value,icon,visible}; merged into the
|
||||
# Node's properties on approve. Empty for scan/mesh sources that don't set it.
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
# --- Curated device facts (3.3.0) -------------------------------------
|
||||
# The inventory row, not the canvas node, owns what the device *is*. A node
|
||||
# only says how it is drawn. `label`/`type` supersede friendly_name/
|
||||
# suggested_type for a device that reached a canvas; the older pair is kept
|
||||
# so discovery imports and the source filters keep working unchanged.
|
||||
label: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
type: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
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)
|
||||
check_method: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
check_target: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Live reachability from the status checker. NOT `status` — that column holds
|
||||
# the inventory lifecycle (pending/approved/hidden) and the two must not be
|
||||
# conflated.
|
||||
status_live: Mapped[str] = mapped_column(String, default="unknown")
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=_now, onupdate=_now
|
||||
)
|
||||
|
||||
class PendingDeviceLink(Base):
|
||||
# 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 InventoryDeviceLink(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
|
||||
a InventoryDevice. On approval, the matching Edge is auto-created when both
|
||||
endpoints exist as canvas Nodes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_device_links"
|
||||
__tablename__ = "device_inventory_links"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
|
||||
+32
-2
@@ -6,11 +6,28 @@ from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee
|
||||
from app.api.routes import (
|
||||
auth,
|
||||
canvas,
|
||||
designs,
|
||||
edges,
|
||||
liveview,
|
||||
media,
|
||||
nodes,
|
||||
proxmox,
|
||||
racks,
|
||||
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
|
||||
from app.core.security import OIDCCSRFMiddleware
|
||||
from app.db.database import init_db
|
||||
|
||||
|
||||
@@ -39,12 +56,21 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(OIDCCSRFMiddleware)
|
||||
app.add_middleware(
|
||||
SessionMiddleware,
|
||||
secret_key=settings.secret_key,
|
||||
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
|
||||
max_age=settings.oidc_transaction_expire_seconds,
|
||||
same_site="lax",
|
||||
https_only=settings.oidc_cookie_secure,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
|
||||
allow_headers=["Authorization", "Content-Type"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||
@@ -52,12 +78,16 @@ 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(racks.router, prefix="/api/v1/racks", tags=["racks"])
|
||||
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(proxmox.router, prefix="/api/v1/proxmox", tags=["proxmox"])
|
||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
||||
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -4,13 +4,24 @@ from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.schemas.utils import normalize_animated
|
||||
from app.schemas.utils import normalize_animated, normalize_marker
|
||||
|
||||
|
||||
class NodeSave(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
label: str
|
||||
# Round-tripped so a save keeps the node pointed at its inventory row. The
|
||||
# device fields below are still accepted at their canvas values and routed
|
||||
# to that row server-side.
|
||||
device_id: str | None = None
|
||||
# Which device facts this canvas edited since it loaded them, if the client
|
||||
# tracked that. The payload always carries a *full* copy of the device — it
|
||||
# was hydrated from the inventory row on load — so without this a save made
|
||||
# for nothing but a moved node would rewrite the row from a snapshot that may
|
||||
# be hours old. `None` (an older client, an import, the MCP server) keeps the
|
||||
# previous behaviour: every fact is a candidate write.
|
||||
changed_facts: list[str] | None = None
|
||||
hostname: str | None = None
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
@@ -29,10 +40,14 @@ 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
|
||||
top_handles: int = 1
|
||||
left_handles: int = 0
|
||||
right_handles: int = 0
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
@@ -47,7 +62,11 @@ class EdgeSave(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
line_style: str | None = None
|
||||
width_mult: float | None = None
|
||||
animated: str = 'none'
|
||||
marker_start: str = 'none'
|
||||
marker_end: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
@@ -57,6 +76,11 @@ class EdgeSave(BaseModel):
|
||||
def validate_animated(cls, v: object) -> str:
|
||||
return normalize_animated(v)
|
||||
|
||||
@field_validator('marker_start', 'marker_end', mode='before')
|
||||
@classmethod
|
||||
def validate_marker(cls, v: object) -> str:
|
||||
return normalize_marker(v)
|
||||
|
||||
|
||||
class CanvasSaveRequest(BaseModel):
|
||||
nodes: list[NodeSave] = []
|
||||
@@ -71,3 +95,8 @@ class CanvasStateResponse(BaseModel):
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
# True once this design's canvas has ever been persisted (a CanvasState row
|
||||
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
|
||||
# who intentionally cleared their canvas (keep it empty). False also for a
|
||||
# missing/uninitialized design.
|
||||
initialized: bool = False
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Kept in sync with DesignType in frontend/src/types.
|
||||
# "network" and "electrical" render the same React Flow canvas and differ only by
|
||||
# palette and node set — the icon drives their presentation. "rack" is a genuinely
|
||||
# different renderer (racks, mounted gear, port-to-port cables), so the frontend
|
||||
# does branch on this field.
|
||||
DESIGN_TYPES = {"network", "electrical", "rack"}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@field_validator("design_type")
|
||||
@classmethod
|
||||
def _known_type(cls, v: str) -> str:
|
||||
if v not in DESIGN_TYPES:
|
||||
raise ValueError(f"design_type must be one of {sorted(DESIGN_TYPES)}")
|
||||
return v
|
||||
|
||||
|
||||
class DesignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
icon: str | None = None
|
||||
|
||||
|
||||
class DesignCopy(BaseModel):
|
||||
"""Create a new design by deep-copying an existing one's canvas."""
|
||||
|
||||
name: str
|
||||
icon: str = "dashboard"
|
||||
|
||||
|
||||
class DesignResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -23,5 +42,10 @@ class DesignResponse(BaseModel):
|
||||
icon: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Populated by list_designs so the "copy from existing" picker can show what
|
||||
# each canvas holds. None on create/update/copy responses (not computed there).
|
||||
node_count: int | None = None
|
||||
group_count: int | None = None
|
||||
text_count: int | None = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.utils import normalize_animated
|
||||
from app.schemas.utils import normalize_animated, normalize_marker
|
||||
|
||||
|
||||
class EdgeBase(BaseModel):
|
||||
@@ -14,7 +14,11 @@ class EdgeBase(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
line_style: str | None = None
|
||||
width_mult: float | None = None
|
||||
animated: str = 'none'
|
||||
marker_start: str = 'none'
|
||||
marker_end: str = 'none'
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
@@ -24,6 +28,11 @@ class EdgeBase(BaseModel):
|
||||
def validate_animated(cls, v: object) -> str:
|
||||
return normalize_animated(v)
|
||||
|
||||
@field_validator('marker_start', 'marker_end', mode='before')
|
||||
@classmethod
|
||||
def validate_marker(cls, v: object) -> str:
|
||||
return normalize_marker(v)
|
||||
|
||||
|
||||
class EdgeCreate(EdgeBase):
|
||||
design_id: str | None = None
|
||||
@@ -36,7 +45,11 @@ class EdgeUpdate(BaseModel):
|
||||
speed: str | None = None
|
||||
custom_color: str | None = None
|
||||
path_style: str | None = None
|
||||
line_style: str | None = None
|
||||
width_mult: float | None = None
|
||||
animated: str | None = None
|
||||
marker_start: str | None = None
|
||||
marker_end: str | None = None
|
||||
source_handle: str | None = None
|
||||
target_handle: str | None = None
|
||||
waypoints: list[dict[str, float]] | None = None
|
||||
@@ -48,9 +61,17 @@ class EdgeUpdate(BaseModel):
|
||||
return None
|
||||
return normalize_animated(v)
|
||||
|
||||
@field_validator('marker_start', 'marker_end', mode='before')
|
||||
@classmethod
|
||||
def validate_marker(cls, v: object) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
return normalize_marker(v)
|
||||
|
||||
|
||||
class EdgeResponse(EdgeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -27,14 +27,26 @@ 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
|
||||
top_handles: int = 1
|
||||
left_handles: int = 0
|
||||
right_handles: int = 0
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
|
||||
# The create_node route resolves None to a free grid slot before persisting.
|
||||
pos_x: float | None = None # type: ignore[assignment]
|
||||
pos_y: float | None = None # type: ignore[assignment]
|
||||
design_id: str | None = None
|
||||
# When a node with the same ip/mac already exists on the target design, the
|
||||
# create/approve endpoints reject with 409 so the UI can ask the user. Set
|
||||
# force=True to bypass that guard and create the duplicate deliberately.
|
||||
force: bool = False
|
||||
|
||||
|
||||
class NodeUpdate(BaseModel):
|
||||
@@ -60,17 +72,26 @@ 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
|
||||
top_handles: int | None = None
|
||||
left_handles: int | None = None
|
||||
right_handles: int | None = None
|
||||
|
||||
|
||||
class NodeResponse(NodeBase):
|
||||
id: str
|
||||
design_id: str | None = None
|
||||
# The Device Inventory row this node draws; None for canvas furniture. The
|
||||
# device fields above are hydrated from that row on read, so the wire shape
|
||||
# is unchanged even though the row, not the node, owns them.
|
||||
device_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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Pydantic v2 schemas for Proxmox VE import.
|
||||
|
||||
Token fields are accepted on requests only and are optional — when omitted the
|
||||
backend falls back to the server-configured token (env). No response schema ever
|
||||
carries a token; secrets are kept out of responses by structural omission.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ProxmoxConnectionRequest(BaseModel):
|
||||
host: str = Field(..., description="Proxmox VE host or IP")
|
||||
port: int = Field(8006, ge=1, le=65535, description="Proxmox API port")
|
||||
token_id: str | None = Field(
|
||||
None, description="API token id 'user@realm!tokenname' (falls back to server env)"
|
||||
)
|
||||
token_secret: str | None = Field(
|
||||
None, description="API token secret (falls back to server env)"
|
||||
)
|
||||
verify_tls: bool = Field(True, description="Verify the Proxmox TLS certificate")
|
||||
|
||||
|
||||
class ProxmoxTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ProxmoxNodeOut(BaseModel):
|
||||
"""A homelable-ready node representation of a Proxmox host / VM / LXC."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
type: str # proxmox | vm | lxc
|
||||
ieee_address: str
|
||||
hostname: str | None = None
|
||||
ip: str | None = None
|
||||
status: str
|
||||
cpu_count: int | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
vendor: str | None = None
|
||||
model: str | None = None
|
||||
parent_ieee: str | None = None
|
||||
|
||||
|
||||
class ProxmoxEdgeOut(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class ProxmoxImportResponse(BaseModel):
|
||||
nodes: list[ProxmoxNodeOut]
|
||||
edges: list[ProxmoxEdgeOut]
|
||||
device_count: int
|
||||
|
||||
|
||||
class ProxmoxImportPendingResponse(BaseModel):
|
||||
"""Result of importing a Proxmox inventory into the pending section."""
|
||||
|
||||
pending_created: int
|
||||
pending_updated: int
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
|
||||
|
||||
class ProxmoxConfig(BaseModel):
|
||||
"""Non-secret Proxmox connection + auto-sync config (GET response).
|
||||
|
||||
Connection fields (host/port/verify_tls) are env-only and read-only here —
|
||||
surfaced for display. ``token_configured`` reflects whether a server-side
|
||||
token is present. Never carries the token itself."""
|
||||
|
||||
host: str = ""
|
||||
port: int = Field(8006, ge=1, le=65535)
|
||||
verify_tls: bool = True
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
token_configured: bool = False
|
||||
|
||||
|
||||
class ProxmoxSyncConfig(BaseModel):
|
||||
"""User-editable auto-sync config (POST body). The ONLY persisted Proxmox
|
||||
settings. Connection fields (host/port/token/verify_tls) are env-only and
|
||||
are deliberately not accepted here."""
|
||||
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
@@ -0,0 +1,302 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
# Kept in sync with the frontend rack model (frontend/src/types).
|
||||
WIDTH_STANDARDS = {"19", "10"}
|
||||
NUMBERINGS = {"bottom-up", "top-down"}
|
||||
CABLE_TYPES = {"ethernet", "fiber"}
|
||||
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
|
||||
RACK_COLUMNS = 12
|
||||
|
||||
|
||||
class RackSave(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
u_height: int = 42
|
||||
width_standard: str = "19"
|
||||
numbering: str = "bottom-up"
|
||||
location: str | None = None
|
||||
style: dict[str, Any] = {}
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
@field_validator("u_height")
|
||||
@classmethod
|
||||
def _positive_height(cls, v: int) -> int:
|
||||
if not 1 <= v <= 100:
|
||||
raise ValueError("u_height must be between 1 and 100")
|
||||
return v
|
||||
|
||||
@field_validator("width_standard")
|
||||
@classmethod
|
||||
def _known_width(cls, v: str) -> str:
|
||||
if v not in WIDTH_STANDARDS:
|
||||
raise ValueError(f"width_standard must be one of {sorted(WIDTH_STANDARDS)}")
|
||||
return v
|
||||
|
||||
@field_validator("numbering")
|
||||
@classmethod
|
||||
def _known_numbering(cls, v: str) -> str:
|
||||
if v not in NUMBERINGS:
|
||||
raise ValueError(f"numbering must be one of {sorted(NUMBERINGS)}")
|
||||
return v
|
||||
|
||||
|
||||
class RackDeviceSave(BaseModel):
|
||||
id: str
|
||||
rack_id: str
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
label: str
|
||||
u_start: int = 1
|
||||
u_height: int = 1
|
||||
col_start: int = 0
|
||||
col_span: int = RACK_COLUMNS
|
||||
faceplate_id: str = "blank-1u"
|
||||
color: str | None = None
|
||||
status: str = "unknown"
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("u_start", "u_height")
|
||||
@classmethod
|
||||
def _positive(cls, v: int) -> int:
|
||||
if v < 1:
|
||||
raise ValueError("u_start and u_height are 1-based and must be >= 1")
|
||||
return v
|
||||
|
||||
@field_validator("col_start")
|
||||
@classmethod
|
||||
def _column_in_grid(cls, v: int) -> int:
|
||||
if not 0 <= v < RACK_COLUMNS:
|
||||
raise ValueError(f"col_start must be within the {RACK_COLUMNS}-column grid")
|
||||
return v
|
||||
|
||||
@field_validator("col_span")
|
||||
@classmethod
|
||||
def _span_in_grid(cls, v: int) -> int:
|
||||
if not 1 <= v <= RACK_COLUMNS:
|
||||
raise ValueError(f"col_span must be between 1 and {RACK_COLUMNS}")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _fits_the_column_grid(self) -> "RackDeviceSave":
|
||||
"""`col_start` and `col_span` are legal apart and still illegal together.
|
||||
|
||||
11 + 12 spans to column 23 of a 12-column grid — each field passes its
|
||||
own check, and the frontend serializer clamps them independently, so
|
||||
nothing caught it before this.
|
||||
"""
|
||||
if self.col_start + self.col_span > RACK_COLUMNS:
|
||||
raise ValueError(
|
||||
f"col_start + col_span must not exceed the {RACK_COLUMNS}-column grid"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class RackCableSave(BaseModel):
|
||||
id: str
|
||||
from_device_id: str
|
||||
from_port_id: str
|
||||
to_device_id: str
|
||||
to_port_id: str
|
||||
type: str = "ethernet"
|
||||
color: str = "#39d353"
|
||||
label: str | None = None
|
||||
label_visible: bool = False
|
||||
# [{key, value, icon, visible}] — free-form, same shape as node properties.
|
||||
properties: list[dict[str, Any]] = []
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _known_type(cls, v: str) -> str:
|
||||
if v not in CABLE_TYPES:
|
||||
raise ValueError(f"type must be one of {sorted(CABLE_TYPES)}")
|
||||
return v
|
||||
|
||||
@field_validator("properties", mode="before")
|
||||
@classmethod
|
||||
def _clean_properties(cls, v: Any) -> list[dict[str, Any]]:
|
||||
"""Keep only usable records — a property with no key draws an empty badge."""
|
||||
if not isinstance(v, list):
|
||||
return []
|
||||
return [p for p in v if isinstance(p, dict) and str(p.get("key", "")).strip()]
|
||||
|
||||
|
||||
class RackSaveRequest(BaseModel):
|
||||
"""Full rack state for one design — upserted, with anything missing pruned.
|
||||
|
||||
Same contract as ``POST /api/v1/canvas/save``: the client owns the state and
|
||||
sends all of it on an explicit Save.
|
||||
"""
|
||||
|
||||
design_id: str
|
||||
racks: list[RackSave] = []
|
||||
devices: list[RackDeviceSave] = []
|
||||
cables: list[RackCableSave] = []
|
||||
# Pan/zoom, stored on the design's shared CanvasState row like the logical canvas.
|
||||
viewport: dict[str, Any] = {}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _devices_fit_their_rack(self) -> "RackSaveRequest":
|
||||
"""A mount must fit inside the rack it names.
|
||||
|
||||
`RackDeviceSave` validates each field in isolation and never sees the
|
||||
rack, so `u_start: 40` in a 12U rack passed. A mount above the top rail
|
||||
draws outside the chassis and nothing in the UI can drag it back, so the
|
||||
server refuses to store it. Rack membership itself is checked in the
|
||||
route, which knows what is already persisted.
|
||||
"""
|
||||
heights = {r.id: r.u_height for r in self.racks}
|
||||
for device in self.devices:
|
||||
u_height = heights.get(device.rack_id)
|
||||
if u_height is None:
|
||||
continue # unknown rack — the route reports that with a 400
|
||||
if device.u_start + device.u_height - 1 > u_height:
|
||||
raise ValueError(
|
||||
f"Device {device.id} does not fit in rack {device.rack_id}: "
|
||||
f"U {device.u_start}–{device.u_start + device.u_height - 1} "
|
||||
f"of {u_height}U"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class RackResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
name: str
|
||||
u_height: int
|
||||
width_standard: str
|
||||
numbering: str
|
||||
location: str | None = None
|
||||
style: dict[str, Any] = {}
|
||||
pos_x: float
|
||||
pos_y: float
|
||||
|
||||
@field_validator("style", mode="before")
|
||||
@classmethod
|
||||
def _coerce_style(cls, v: Any) -> dict[str, Any]:
|
||||
return v if isinstance(v, dict) else {}
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackDeviceResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
rack_id: str
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
label: str
|
||||
u_start: int
|
||||
u_height: int
|
||||
col_start: int
|
||||
col_span: int
|
||||
faceplate_id: str
|
||||
color: str | None = None
|
||||
status: str
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("ports", mode="before")
|
||||
@classmethod
|
||||
def _coerce_ports(cls, v: Any) -> list[Any]:
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackCableResponse(BaseModel):
|
||||
id: str
|
||||
design_id: str
|
||||
from_device_id: str
|
||||
from_port_id: str
|
||||
to_device_id: str
|
||||
to_port_id: str
|
||||
type: str
|
||||
color: str
|
||||
label: str | None = None
|
||||
label_visible: bool = False
|
||||
properties: list[dict[str, Any]] = []
|
||||
|
||||
@field_validator("properties", mode="before")
|
||||
@classmethod
|
||||
def _list_properties(cls, v: Any) -> list[dict[str, Any]]:
|
||||
"""Rows written before the column existed read back as NULL."""
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
@field_validator("label_visible", mode="before")
|
||||
@classmethod
|
||||
def _bool_label_visible(cls, v: Any) -> bool:
|
||||
return bool(v)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RackStateResponse(BaseModel):
|
||||
racks: list[RackResponse]
|
||||
devices: list[RackDeviceResponse]
|
||||
cables: list[RackCableResponse]
|
||||
viewport: dict[str, Any] = {}
|
||||
|
||||
|
||||
class RackServiceInfo(BaseModel):
|
||||
"""One service fingerprinted on a device, as the rack view prints it."""
|
||||
|
||||
port: int | None = None
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class RackInventoryItem(BaseModel):
|
||||
"""A Device Inventory entry offered to the rack tray.
|
||||
|
||||
Carries the technical facts the logical canvas already knows about the same
|
||||
hardware (`node_*`), so a mount can print them without the user leaving the
|
||||
rack. Everything node-side is read-only here: the logical canvas owns it.
|
||||
"""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
suggested_type: str | None = None
|
||||
ip: str | None = None
|
||||
status: str
|
||||
discovery_source: str | None = None
|
||||
# Inventory-side technical detail, from discovery.
|
||||
mac: str | None = None
|
||||
hostname: str | None = None
|
||||
os: str | None = None
|
||||
services: list[RackServiceInfo] = []
|
||||
# Live status of the matching canvas node, when the device is on one.
|
||||
node_id: str | None = None
|
||||
node_status: str | None = None
|
||||
# What that node holds — the logical view of the same box.
|
||||
node_label: str | None = None
|
||||
node_type: str | None = None
|
||||
node_ip: str | None = None
|
||||
node_mac: str | None = None
|
||||
node_hostname: str | None = None
|
||||
node_os: str | None = None
|
||||
node_check_method: str | None = None
|
||||
node_design_id: str | None = None
|
||||
node_design_name: str | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
# True when this device is already mounted somewhere in this design.
|
||||
racked: bool = False
|
||||
|
||||
@field_validator("node_last_seen")
|
||||
@classmethod
|
||||
def _utc(cls, v: datetime | None) -> datetime | None:
|
||||
"""Stamp UTC on a naive timestamp before it goes on the wire.
|
||||
|
||||
SQLite has no timezone type: an aware datetime written to `last_seen`
|
||||
reads back naive, and Pydantic then serializes it without an offset.
|
||||
`new Date("2026-08-08T10:00:00")` in the browser is *local* time, so the
|
||||
rack printed a last-seen shifted by the viewer's offset.
|
||||
"""
|
||||
if v is not None and v.tzinfo is None:
|
||||
return v.replace(tzinfo=timezone.utc)
|
||||
return v
|
||||
|
||||
|
||||
class RackInventoryResponse(BaseModel):
|
||||
items: list[RackInventoryItem]
|
||||
+138
-2
@@ -1,10 +1,10 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class PendingDeviceResponse(BaseModel):
|
||||
class InventoryDeviceResponse(BaseModel):
|
||||
id: str
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
@@ -14,17 +14,153 @@ class PendingDeviceResponse(BaseModel):
|
||||
suggested_type: str | None
|
||||
status: str
|
||||
discovery_source: str | None
|
||||
# All sources that have observed this device (e.g. ["arp", "proxmox"]). Drives
|
||||
# the inventory source filter + badges; falls back to [discovery_source].
|
||||
discovery_sources: list[str] = []
|
||||
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
|
||||
# Display properties carried from discovery (e.g. Proxmox specs). Merged into
|
||||
# the node on approve; empty for scan/mesh sources that don't set them.
|
||||
properties: list[Any] = []
|
||||
discovered_at: datetime
|
||||
# Curated device facts (3.3.0). `label`/`type` supersede friendly_name/
|
||||
# suggested_type once the user has named the device; the older pair stays for
|
||||
# discovery imports and the source filters.
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
# Live reachability, distinct from `status` (the pending/approved/hidden
|
||||
# lifecycle).
|
||||
status_live: str = "unknown"
|
||||
last_seen: datetime | None = None
|
||||
last_scan: datetime | None = None
|
||||
response_time_ms: int | None = None
|
||||
updated_at: datetime | None = None
|
||||
# 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
|
||||
|
||||
@field_validator("properties", "discovery_sources", "services", mode="before")
|
||||
@classmethod
|
||||
def _coerce_list(cls, v: Any) -> list[Any]:
|
||||
# Legacy rows (columns added by migration) have these = NULL.
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
@field_validator("status_live", mode="before")
|
||||
@classmethod
|
||||
def _coerce_status_live(cls, v: Any) -> str:
|
||||
return v if isinstance(v, str) and v else "unknown"
|
||||
|
||||
@field_validator("show_hardware", mode="before")
|
||||
@classmethod
|
||||
def _coerce_show_hardware(cls, v: Any) -> bool:
|
||||
return bool(v)
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
"""Sources a hand-made inventory entry may claim.
|
||||
|
||||
``rack`` marks gear created from a rack canvas. Those rows live in the Device
|
||||
Inventory like any other, but they describe a physical mount rather than a host
|
||||
on the network, so they are never placed on a logical canvas.
|
||||
"""
|
||||
MANUAL_SOURCES = {"manual", "rack"}
|
||||
|
||||
|
||||
class InventoryDeviceCreate(BaseModel):
|
||||
"""Manually add an entry to the Device Inventory.
|
||||
|
||||
Used when the user documents hardware no scan can find — a dumb switch, a
|
||||
patch panel, a machine that is powered off. Lands with
|
||||
`discovery_source="manual"` (or `"rack"`) so the inventory filters can tell
|
||||
it apart.
|
||||
"""
|
||||
|
||||
hostname: str
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
suggested_type: str | None = None
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
properties: list[Any] = []
|
||||
discovery_source: str = "manual"
|
||||
# Curated fields, so a hand-made entry can carry everything the edit modal
|
||||
# shows rather than needing a create-then-PATCH round trip.
|
||||
os: str | None = None
|
||||
services: list[Any] = []
|
||||
friendly_name: str | None = None
|
||||
device_subtype: str | None = None
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool = False
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
|
||||
@field_validator("discovery_source")
|
||||
@classmethod
|
||||
def _known_source(cls, v: str) -> str:
|
||||
if v not in MANUAL_SOURCES:
|
||||
raise ValueError(f"discovery_source must be one of {sorted(MANUAL_SOURCES)}")
|
||||
return v
|
||||
|
||||
|
||||
class InventoryDeviceUpdate(BaseModel):
|
||||
"""Partial edit of an inventory row — the write half of the detail modal.
|
||||
|
||||
Every field is optional and only what the client sends is applied
|
||||
(`exclude_unset`), so a caller editing one field never clears the rest.
|
||||
Lifecycle (`status`) and discovery bookkeeping (`discovery_source(s)`,
|
||||
`discovered_at`, `ieee_address`) are deliberately not editable here: those
|
||||
are owned by the approve/hide routes and the importers.
|
||||
"""
|
||||
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
hostname: str | None = None
|
||||
os: str | None = None
|
||||
label: str | None = None
|
||||
type: str | None = None
|
||||
suggested_type: str | None = None
|
||||
friendly_name: str | None = None
|
||||
device_subtype: str | None = None
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
services: list[Any] | None = None
|
||||
properties: list[Any] | None = None
|
||||
notes: str | None = None
|
||||
cpu_count: int | None = None
|
||||
cpu_model: str | None = None
|
||||
ram_gb: float | None = None
|
||||
disk_gb: float | None = None
|
||||
show_hardware: bool | None = None
|
||||
check_method: str | None = None
|
||||
check_target: str | None = None
|
||||
|
||||
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
@@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str:
|
||||
if v in ('snake', 'flow', 'basic'):
|
||||
return str(v)
|
||||
return 'none'
|
||||
|
||||
|
||||
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
|
||||
|
||||
|
||||
def normalize_marker(v: object) -> str:
|
||||
"""Normalize an edge endpoint marker to a shape string.
|
||||
|
||||
Legacy saves stored a boolean (True = filled arrow); coerce those and any
|
||||
unknown value to a valid MarkerShape ('none' when off/unknown).
|
||||
"""
|
||||
if v is True or v == 1 or v == '1':
|
||||
return 'arrow'
|
||||
if v is False or v == 0 or v == '0' or v is None:
|
||||
return 'none'
|
||||
if isinstance(v, str) and v in MARKER_SHAPES:
|
||||
return v
|
||||
return 'none'
|
||||
|
||||
@@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel):
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeConfig(BaseModel):
|
||||
"""Non-secret Zigbee connection + auto-sync config (GET response).
|
||||
|
||||
MQTT connection fields (host/port/base_topic/tls) are env-only and
|
||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
||||
(username/password) are never carried."""
|
||||
|
||||
mqtt_host: str = ""
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
base_topic: str = "zigbee2mqtt"
|
||||
mqtt_tls: bool = False
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
host_configured: bool = False
|
||||
|
||||
|
||||
class ZigbeeSyncConfig(BaseModel):
|
||||
"""User-editable auto-sync config (POST body). The ONLY persisted Zigbee
|
||||
settings. Connection fields (host/port/credentials/topic/tls) are env-only
|
||||
and are deliberately not accepted here."""
|
||||
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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
|
||||
|
||||
|
||||
class ZwaveConfig(BaseModel):
|
||||
"""Non-secret Z-Wave connection + auto-sync config (GET response).
|
||||
|
||||
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
|
||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
||||
(username/password) are never carried."""
|
||||
|
||||
mqtt_host: str = ""
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
prefix: str = "zwave"
|
||||
gateway_name: str = "zwavejs2mqtt"
|
||||
mqtt_tls: bool = False
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
host_configured: bool = False
|
||||
|
||||
|
||||
class ZwaveSyncConfig(BaseModel):
|
||||
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
|
||||
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
|
||||
env-only and are deliberately not accepted here."""
|
||||
|
||||
sync_enabled: bool = False
|
||||
sync_interval: int = Field(3600, ge=300)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Helpers for the multi-valued ``InventoryDevice.discovery_sources`` set.
|
||||
|
||||
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
|
||||
import) accumulates every source that has seen it, so it surfaces under each
|
||||
matching inventory filter. Order is preserved (origin first) and duplicates are
|
||||
dropped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def add_source(sources: Iterable[str] | None, source: str | None) -> list[str]:
|
||||
"""Return ``sources`` with ``source`` appended if not already present."""
|
||||
out = [s for s in (sources or []) if s]
|
||||
if source and source not in out:
|
||||
out.append(source)
|
||||
return out
|
||||
@@ -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] = {
|
||||
@@ -156,12 +213,16 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
||||
if mac_type == "iot":
|
||||
return "iot"
|
||||
|
||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
||||
priority = [
|
||||
"proxmox", "nas", "kvm", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch",
|
||||
]
|
||||
found: set[str] = set()
|
||||
for p in open_ports:
|
||||
port = p["port"]
|
||||
proto = p.get("protocol", "tcp")
|
||||
sig = match_port(port, proto)
|
||||
# Pass the HTTP-probe signals through: port-agnostic signatures (port: null,
|
||||
# e.g. PiKVM, Unraid) match on title/headers alone and are invisible otherwise.
|
||||
sig = match_service(port, proto, p.get("banner"), p.get("http_signals"))
|
||||
if sig and sig.get("suggested_node_type"):
|
||||
found.add(sig["suggested_node_type"])
|
||||
if port in _PORT_TYPE_HINTS:
|
||||
|
||||
@@ -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,631 @@
|
||||
"""Keep a canvas :class:`Node` and its Device Inventory row in step.
|
||||
|
||||
The inventory row owns what a device *is* — addresses, services, properties,
|
||||
notes, hardware, check method. A node owns only how that device is drawn on one
|
||||
canvas. This module holds the matching and merging rules shared by:
|
||||
|
||||
* the one-off backfill that links pre-3.3.0 nodes to inventory rows,
|
||||
* the approve / node-create paths, which link instead of copying,
|
||||
* the canvas save write-through, which pushes a node edit back to the row.
|
||||
|
||||
Nothing here commits — the caller owns the transaction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from app.services.discovery_sources import add_source
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canvas furniture: annotations, not hardware. These never get an inventory row.
|
||||
FURNITURE_TYPES = frozenset({"group", "groupRect", "text"})
|
||||
|
||||
# Source tag for a device that only ever existed as a canvas node — the backfill
|
||||
# mints its inventory row.
|
||||
CANVAS_SOURCE = "canvas"
|
||||
|
||||
# Scalar facts the inventory row owns. Order matters only for readability.
|
||||
DEVICE_SCALARS = (
|
||||
"hostname",
|
||||
"ip",
|
||||
"mac",
|
||||
"os",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"check_method",
|
||||
"check_target",
|
||||
)
|
||||
|
||||
|
||||
def is_furniture(node_type: str | None) -> bool:
|
||||
return (node_type or "") in FURNITURE_TYPES
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split an ``ip`` field into individual addresses.
|
||||
|
||||
A node or device may carry several comma-separated addresses, so identity
|
||||
matching compares per token — same rule as ``node_dedupe._ip_tokens``.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _same_ieee(left: str | None, right: str | None) -> bool:
|
||||
"""IEEE addresses compare case-insensitively — the same radio either way."""
|
||||
if _blank(left) or _blank(right):
|
||||
return False
|
||||
return str(left).lower() == str(right).lower()
|
||||
|
||||
|
||||
async def _ieee_owner(db: AsyncSession, ieee: str, *, other_than: str | None) -> InventoryDevice | None:
|
||||
"""The row already holding ``ieee``, if it is not ``other_than``.
|
||||
|
||||
``device_inventory.ieee_address`` is UNIQUE, so writing an address a second
|
||||
row owns raises. Callers ask first and leave the address where it is.
|
||||
"""
|
||||
stmt = select(InventoryDevice).where(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
if other_than is not None:
|
||||
# `id != NULL` is NULL in SQL and would match nothing — only narrow when
|
||||
# there is a row to exclude.
|
||||
stmt = stmt.where(InventoryDevice.id != other_than)
|
||||
return (await db.execute(stmt)).scalars().first()
|
||||
|
||||
|
||||
async def _drop_taken_ieee(
|
||||
db: AsyncSession, facts: Mapping[str, Any], device: InventoryDevice | None
|
||||
) -> Mapping[str, Any]:
|
||||
"""``facts`` without an ``ieee_address`` another inventory row already owns.
|
||||
|
||||
Returned unchanged in the common case. The column is UNIQUE, so writing a
|
||||
duplicate raises mid-merge — and during the backfill that would cost the node
|
||||
its link. The address belongs to whichever row holds it; identity for *this*
|
||||
device was established by ip or mac.
|
||||
"""
|
||||
ieee = facts.get("ieee_address")
|
||||
if _blank(ieee) or (device is not None and _same_ieee(device.ieee_address, ieee)):
|
||||
return facts
|
||||
owner = await _ieee_owner(db, str(ieee), other_than=device.id if device else None)
|
||||
if owner is None:
|
||||
return facts
|
||||
logger.warning(
|
||||
"IEEE %s is already held by device %s — leaving it off %s",
|
||||
ieee, owner.id, device.id if device else "the new row",
|
||||
)
|
||||
return {k: v for k, v in facts.items() if k != "ieee_address"}
|
||||
|
||||
|
||||
async def find_device_for(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
ieee: str | None,
|
||||
) -> InventoryDevice | None:
|
||||
"""Find the inventory row describing this host, or ``None``.
|
||||
|
||||
Precedence is ieee > ip > mac, matching ``find_duplicate_node`` and the
|
||||
bulk-approve skip order so a device is identified the same way everywhere.
|
||||
Hidden rows are eligible: a hidden device is still that device, and silently
|
||||
minting a second row for it would resurrect the duplicate the user hid.
|
||||
"""
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
# Case-insensitive: `0x00124B00…` and `0x00124b00…` are the same radio,
|
||||
# and an exact comparison here would mint a second row for it — one the
|
||||
# UNIQUE index then refuses.
|
||||
conds.append(func.lower(InventoryDevice.ieee_address) == ieee.lower())
|
||||
for tok in ip_toks:
|
||||
# Narrow with a substring match, then confirm per token below — an exact
|
||||
# comparison misses rows holding several addresses.
|
||||
conds.append(InventoryDevice.ip.contains(tok))
|
||||
if mac:
|
||||
conds.append(InventoryDevice.mac == mac)
|
||||
if not conds:
|
||||
return None
|
||||
|
||||
candidates = (
|
||||
await db.execute(select(InventoryDevice).where(or_(*conds)).order_by(InventoryDevice.discovered_at))
|
||||
).scalars().all()
|
||||
|
||||
for device in candidates:
|
||||
if ieee and _same_ieee(device.ieee_address, ieee):
|
||||
return device
|
||||
for device in candidates:
|
||||
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
|
||||
# the SQL `contains`.
|
||||
if ip_toks and set(_ip_tokens(device.ip)) & set(ip_toks):
|
||||
return device
|
||||
for device in candidates:
|
||||
if mac and device.mac == mac:
|
||||
return device
|
||||
return None
|
||||
|
||||
|
||||
def merge_properties(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two property lists on ``key`` (case-insensitive); incoming wins.
|
||||
|
||||
Order-stable: existing keys keep their position, new ones are appended, so a
|
||||
user's arrangement survives a merge.
|
||||
"""
|
||||
out: list[Any] = [dict(p) if isinstance(p, dict) else p for p in (base or [])]
|
||||
index: dict[str, int] = {}
|
||||
for i, prop in enumerate(out):
|
||||
if isinstance(prop, dict) and prop.get("key") is not None:
|
||||
index[str(prop["key"]).lower()] = i
|
||||
|
||||
for prop in incoming or []:
|
||||
if not isinstance(prop, dict) or prop.get("key") is None:
|
||||
if prop not in out:
|
||||
out.append(prop)
|
||||
continue
|
||||
key = str(prop["key"]).lower()
|
||||
pos = index.get(key)
|
||||
if pos is None:
|
||||
out.append(dict(prop))
|
||||
index[key] = len(out) - 1
|
||||
continue
|
||||
current = out[pos]
|
||||
if not isinstance(current, dict):
|
||||
out[pos] = dict(prop)
|
||||
continue
|
||||
merged = {**current, **{k: v for k, v in prop.items() if not _blank(v)}}
|
||||
# Keys match case-insensitively but the display spelling is the user's —
|
||||
# "rack" arriving must not rewrite their "Rack".
|
||||
merged["key"] = current.get("key", prop["key"])
|
||||
# `visible` is a real False, not an empty value — carry it explicitly.
|
||||
if "visible" in prop:
|
||||
merged["visible"] = prop["visible"]
|
||||
out[pos] = merged
|
||||
return out
|
||||
|
||||
|
||||
def _service_key(svc: Any) -> Any:
|
||||
if not isinstance(svc, dict):
|
||||
return repr(svc)
|
||||
return (svc.get("port"), svc.get("protocol"), (svc.get("service_name") or "").lower())
|
||||
|
||||
|
||||
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
|
||||
"""Union two service lists on (port, protocol, name); incoming wins."""
|
||||
out: list[Any] = [dict(s) if isinstance(s, dict) else s for s in (base or [])]
|
||||
index = {_service_key(s): i for i, s in enumerate(out)}
|
||||
for svc in incoming or []:
|
||||
key = _service_key(svc)
|
||||
pos = index.get(key)
|
||||
if pos is None:
|
||||
out.append(dict(svc) if isinstance(svc, dict) else svc)
|
||||
index[key] = len(out) - 1
|
||||
elif isinstance(svc, dict) and isinstance(out[pos], dict):
|
||||
out[pos] = {**out[pos], **svc}
|
||||
else:
|
||||
out[pos] = svc
|
||||
return out
|
||||
|
||||
|
||||
# Observations rather than edits: the checker and the scanner write these, so a
|
||||
# client never lists them as changed and they survive a `changed_fields` filter.
|
||||
_LIVE_FACT_FIELDS = frozenset({"status", "last_seen", "last_scan", "response_time_ms"})
|
||||
|
||||
|
||||
def changed_facts(device: InventoryDevice, facts: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The subset of ``facts`` that actually differs from the row.
|
||||
|
||||
A canvas save sends a *full* copy of the device — the facts were hydrated
|
||||
into the node when the canvas loaded — so a save triggered by nothing but a
|
||||
node being dragged would otherwise rewrite the row from a snapshot that may
|
||||
be minutes or hours old, silently reverting an edit made meanwhile in the
|
||||
inventory modal, on another canvas, or by the scanner. Narrowing to what the
|
||||
sender changed turns the write-through from "push my whole snapshot" into
|
||||
"push my edit", so two writers only collide on the same field.
|
||||
|
||||
The comparison mirrors :func:`merge_facts_into_device`: a blank incoming
|
||||
value is not a change (it never clears an established one), and a list
|
||||
counts as changed only when it would actually be replaced by a different
|
||||
one. Ambiguity is resolved toward reporting a change — a false positive is
|
||||
the old behaviour for that field, a false negative would drop a real edit.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
for field in (*DEVICE_SCALARS, "label", "type"):
|
||||
incoming = facts.get(field)
|
||||
if _blank(incoming) or incoming == getattr(device, field, None):
|
||||
continue
|
||||
out[field] = incoming
|
||||
|
||||
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
|
||||
out["ieee_address"] = facts["ieee_address"]
|
||||
if facts.get("show_hardware") and not device.show_hardware:
|
||||
out["show_hardware"] = facts["show_hardware"]
|
||||
|
||||
if "properties" in facts and list(facts["properties"] or []) != list(device.properties or []):
|
||||
out["properties"] = facts["properties"]
|
||||
if "services" in facts and list(facts["services"] or []) != list(device.services or []):
|
||||
out["services"] = facts["services"]
|
||||
|
||||
# Live observations, not edits: carried through only where the merge would
|
||||
# have used them — filling a row that has never been checked.
|
||||
if facts.get("status") and device.status_live in (None, "", "unknown"):
|
||||
out["status"] = facts["status"]
|
||||
for field in ("last_seen", "last_scan", "response_time_ms"):
|
||||
if facts.get(field) is not None:
|
||||
out[field] = facts[field]
|
||||
return out
|
||||
|
||||
|
||||
def merge_facts_into_device(
|
||||
device: InventoryDevice,
|
||||
facts: Mapping[str, Any],
|
||||
*,
|
||||
overwrite_scalars: bool,
|
||||
replace_lists: bool,
|
||||
) -> None:
|
||||
"""Fold one view of a device into its inventory row, in place.
|
||||
|
||||
``facts`` is a plain mapping — what a canvas save sent, or what a legacy
|
||||
node's columns held — so this rule lives in one place regardless of where
|
||||
the view came from.
|
||||
|
||||
Two independent knobs, because the callers need three combinations:
|
||||
|
||||
* ``overwrite_scalars`` — a non-blank incoming value replaces the row's.
|
||||
True for the backfill (nodes are visited oldest-edit-first, so the most
|
||||
recently edited canvas is the last writer and wins) and for a user's save.
|
||||
False on approve, where the row was just discovered and the node is only a
|
||||
placement. A blank *never* clears an established value in either mode.
|
||||
* ``replace_lists`` — properties/services are taken wholesale rather than
|
||||
unioned. True only for a user's save: otherwise a property they deleted
|
||||
would come straight back on the next one. The backfill unions, so nothing
|
||||
any canvas recorded is lost. It applies list by list: a list absent from
|
||||
``facts`` is left alone even in replace mode, so a partial update never
|
||||
clears the one it did not send.
|
||||
"""
|
||||
for field in (*DEVICE_SCALARS, "label", "type"):
|
||||
incoming = facts.get(field)
|
||||
if _blank(incoming):
|
||||
continue
|
||||
if overwrite_scalars or _blank(getattr(device, field, None)):
|
||||
setattr(device, field, incoming)
|
||||
|
||||
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
|
||||
# Identity, never overwritten — two IEEEs mean two devices.
|
||||
device.ieee_address = facts["ieee_address"]
|
||||
if facts.get("show_hardware") and not device.show_hardware:
|
||||
device.show_hardware = True
|
||||
|
||||
# Per list, and only for one the caller actually sent: a partial update
|
||||
# carrying properties alone must leave services as they were, not blank them.
|
||||
if replace_lists and "properties" in facts:
|
||||
device.properties = list(facts["properties"] or [])
|
||||
else:
|
||||
device.properties = merge_properties(device.properties, facts.get("properties"))
|
||||
if replace_lists and "services" in facts:
|
||||
device.services = list(facts["services"] or [])
|
||||
else:
|
||||
device.services = merge_services(device.services, facts.get("services"))
|
||||
|
||||
# Live status: keep the freshest observation rather than the last writer.
|
||||
last_seen, status, last_scan = facts.get("last_seen"), facts.get("status"), facts.get("last_scan")
|
||||
if last_seen and (device.last_seen is None or last_seen > device.last_seen):
|
||||
device.last_seen = last_seen
|
||||
device.status_live = status or device.status_live
|
||||
device.response_time_ms = facts.get("response_time_ms")
|
||||
elif device.status_live in (None, "", "unknown") and status:
|
||||
device.status_live = status
|
||||
if last_scan and (device.last_scan is None or last_scan > device.last_scan):
|
||||
device.last_scan = last_scan
|
||||
|
||||
|
||||
def device_from_facts(facts: Mapping[str, Any]) -> InventoryDevice:
|
||||
"""Mint the inventory row for a device that has none.
|
||||
|
||||
Tagged ``canvas`` so the inventory filters can tell hand-drawn gear apart
|
||||
from anything a scan or import found.
|
||||
"""
|
||||
return InventoryDevice(
|
||||
label=facts.get("label"),
|
||||
type=facts.get("type"),
|
||||
hostname=facts.get("hostname"),
|
||||
ip=facts.get("ip"),
|
||||
mac=facts.get("mac"),
|
||||
os=facts.get("os"),
|
||||
ieee_address=facts.get("ieee_address"),
|
||||
services=list(facts.get("services") or []),
|
||||
properties=list(facts.get("properties") or []),
|
||||
notes=facts.get("notes"),
|
||||
cpu_count=facts.get("cpu_count"),
|
||||
cpu_model=facts.get("cpu_model"),
|
||||
ram_gb=facts.get("ram_gb"),
|
||||
disk_gb=facts.get("disk_gb"),
|
||||
show_hardware=bool(facts.get("show_hardware")),
|
||||
check_method=facts.get("check_method"),
|
||||
check_target=facts.get("check_target"),
|
||||
suggested_type=facts.get("type"),
|
||||
friendly_name=facts.get("label"),
|
||||
# On a canvas already, so it is past the pending queue.
|
||||
status="approved",
|
||||
status_live=facts.get("status") or "unknown",
|
||||
last_seen=facts.get("last_seen"),
|
||||
last_scan=facts.get("last_scan"),
|
||||
response_time_ms=facts.get("response_time_ms"),
|
||||
discovery_source=CANVAS_SOURCE,
|
||||
discovery_sources=[CANVAS_SOURCE],
|
||||
)
|
||||
|
||||
|
||||
async def link_facts(
|
||||
db: AsyncSession,
|
||||
node: Node,
|
||||
facts: Mapping[str, Any],
|
||||
*,
|
||||
overwrite_scalars: bool = False,
|
||||
replace_lists: bool = False,
|
||||
only_changed: bool = False,
|
||||
changed_fields: list[str] | None = None,
|
||||
) -> InventoryDevice | None:
|
||||
"""Point one node at its inventory row, creating or merging as needed.
|
||||
|
||||
``facts`` is this node's view of the device — the fields a canvas save sent,
|
||||
or a legacy node's columns during the backfill. Returns the row, or ``None``
|
||||
for canvas furniture. Flushes so a freshly minted row has an id to link to,
|
||||
but does not commit.
|
||||
|
||||
Two narrowings turn a save from "push my whole snapshot" into "push my edit",
|
||||
and they compose. Identity matching always uses the full ``facts`` — the row
|
||||
has to be found before it can be narrowed against.
|
||||
|
||||
* ``changed_fields`` — what the sender says it edited since it loaded the
|
||||
device. Authoritative: a fact absent from the list is not written even when
|
||||
it differs, because the difference means the *row* moved on, not the sender.
|
||||
* ``only_changed`` — drop facts already equal to the row (see
|
||||
:func:`changed_facts`), so a no-op save writes nothing at all.
|
||||
"""
|
||||
if is_furniture(node.type):
|
||||
node.device_id = None
|
||||
return None
|
||||
|
||||
device = None
|
||||
if node.device_id:
|
||||
device = await db.get(InventoryDevice, node.device_id)
|
||||
if device is None:
|
||||
device = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
|
||||
# The IEEE is UNIQUE across the inventory. Where another row already owns the
|
||||
# one these facts carry, drop it rather than write a duplicate: identity has
|
||||
# been resolved to *this* device by ip or mac, and the address stays with the
|
||||
# row holding it.
|
||||
facts = await _drop_taken_ieee(db, facts, device)
|
||||
|
||||
if device is None:
|
||||
device = device_from_facts(facts)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
else:
|
||||
merged = dict(facts)
|
||||
if changed_fields is not None:
|
||||
keep = set(changed_fields) | _LIVE_FACT_FIELDS
|
||||
merged = {k: v for k, v in merged.items() if k in keep}
|
||||
if only_changed:
|
||||
merged = changed_facts(device, merged)
|
||||
merge_facts_into_device(
|
||||
device,
|
||||
merged,
|
||||
overwrite_scalars=overwrite_scalars,
|
||||
replace_lists=replace_lists,
|
||||
)
|
||||
device.discovery_sources = add_source(device.discovery_sources, CANVAS_SOURCE)
|
||||
|
||||
node.device_id = device.id
|
||||
return device
|
||||
|
||||
|
||||
def node_columns(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The subset of a node payload that is still a `nodes` column.
|
||||
|
||||
The wire shape carries the device facts flat on the node; they belong to the
|
||||
inventory row now, so they are dropped here and applied through
|
||||
:func:`link_facts` instead.
|
||||
"""
|
||||
allowed = {c.name for c in Node.__table__.columns}
|
||||
return {k: v for k, v in payload.items() if k in allowed}
|
||||
|
||||
|
||||
# Fields that are both a node column and a device fact: the node keeps a copy so
|
||||
# a half-migrated database still renders, but the row is the truth.
|
||||
_SHARED_FIELDS = ("label", "type")
|
||||
|
||||
# Everything the inventory row owns, as it appears in a node payload.
|
||||
DEVICE_FACT_FIELDS = (
|
||||
*DEVICE_SCALARS, "services", "properties", "show_hardware", "status", *_SHARED_FIELDS,
|
||||
)
|
||||
|
||||
|
||||
def facts_from_update(sent: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""The device facts inside a partial node update — only what was sent.
|
||||
|
||||
An omitted field must not clear the row, so unsent keys are simply absent.
|
||||
"""
|
||||
return {k: v for k, v in sent.items() if k in DEVICE_FACT_FIELDS}
|
||||
|
||||
|
||||
def facts_from_payload(payload: Mapping[str, Any], *, label: str, node_type: str) -> dict[str, Any]:
|
||||
"""The device facts inside a node save/create payload.
|
||||
|
||||
The wire shape still carries them flat on the node, so this is where they
|
||||
are separated from the presentation fields the node itself keeps.
|
||||
"""
|
||||
facts: dict[str, Any] = {
|
||||
field: payload.get(field)
|
||||
for field in (*DEVICE_SCALARS, "services", "properties", "show_hardware", "status")
|
||||
}
|
||||
facts["label"] = label
|
||||
facts["type"] = node_type
|
||||
return facts
|
||||
|
||||
|
||||
async def load_devices_for(db: AsyncSession, nodes: list[Node]) -> dict[str, InventoryDevice]:
|
||||
"""Fetch the inventory rows a batch of nodes points at, keyed by device id."""
|
||||
ids = {n.device_id for n in nodes if n.device_id}
|
||||
if not ids:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(ids)))
|
||||
).scalars().all()
|
||||
return {d.id: d for d in rows}
|
||||
|
||||
|
||||
def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
|
||||
"""A node as the API reports it: presentation from the node, facts from the row.
|
||||
|
||||
The device fields stay on the wire exactly where they have always been, so
|
||||
every reader — the canvas, the live view, the MCP server — is unaffected by
|
||||
the split. Canvas furniture has no row and simply reports the defaults.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
c.name: getattr(node, c.name) for c in node.__table__.columns
|
||||
}
|
||||
if device is None:
|
||||
return payload
|
||||
|
||||
for field in DEVICE_SCALARS:
|
||||
payload[field] = getattr(device, field, None)
|
||||
payload["label"] = device.label or node.label
|
||||
payload["type"] = device.type or node.type
|
||||
payload["services"] = device.services or []
|
||||
payload["properties"] = device.properties or []
|
||||
payload["show_hardware"] = bool(device.show_hardware)
|
||||
payload["ieee_address"] = device.ieee_address
|
||||
payload["status"] = device.status_live or "unknown"
|
||||
payload["last_seen"] = device.last_seen
|
||||
payload["last_scan"] = device.last_scan
|
||||
payload["response_time_ms"] = device.response_time_ms
|
||||
return payload
|
||||
|
||||
|
||||
# The device columns `nodes` carried before 3.3.0. They are read once, by the
|
||||
# backfill, and then dropped — so they are named here as raw SQL rather than as
|
||||
# model attributes that no longer exist.
|
||||
_LEGACY_NODE_COLUMNS = (
|
||||
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
|
||||
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
|
||||
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
|
||||
"response_time_ms",
|
||||
)
|
||||
|
||||
|
||||
async def _legacy_columns_present(db: AsyncSession) -> list[str]:
|
||||
"""Which pre-3.3.0 device columns still exist on `nodes`."""
|
||||
rows = (await db.execute(text("PRAGMA table_info(nodes)"))).all()
|
||||
present = {r[1] for r in rows}
|
||||
return [c for c in _LEGACY_NODE_COLUMNS if c in present]
|
||||
|
||||
|
||||
def _decode_json(value: Any) -> Any:
|
||||
"""Raw SQL hands back JSON columns as text; the ORM would have decoded them."""
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
return value
|
||||
|
||||
|
||||
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
|
||||
|
||||
Non-destructive: it writes ``nodes.device_id`` and fills the inventory row,
|
||||
and never deletes a node or a row. Two nodes on two canvases describing the
|
||||
same host converge on one row — that convergence is the point.
|
||||
|
||||
Nodes are processed oldest-edit-first so that, where two canvases disagree
|
||||
on a scalar, the most recently edited node is the last writer and wins;
|
||||
properties and services are unioned, so nothing any canvas recorded is lost.
|
||||
|
||||
Reads the legacy columns with raw SQL because the model no longer declares
|
||||
them — this runs on the boot that drops them, once. Returns counts for the
|
||||
boot log, ``skipped`` among them: a node whose merge violates a constraint is
|
||||
left unlinked instead of aborting the run. Does not commit.
|
||||
"""
|
||||
legacy = await _legacy_columns_present(db)
|
||||
if not legacy:
|
||||
# Already migrated: the columns are gone, so nothing can be left to read.
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
placeholders = ", ".join(legacy)
|
||||
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f"SELECT id, label, type, design_id, {placeholders} FROM nodes "
|
||||
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
|
||||
"ORDER BY updated_at, created_at, id"
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
if not rows:
|
||||
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
linked = created = merged = skipped = 0
|
||||
for row in rows:
|
||||
facts: dict[str, Any] = {c: row[c] for c in legacy}
|
||||
facts["services"] = _decode_json(facts.get("services")) or []
|
||||
facts["properties"] = _decode_json(facts.get("properties")) or []
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
# One savepoint per node: a row the merge cannot write — a duplicate
|
||||
# IEEE, a constraint no longer met — is dropped on its own rather than
|
||||
# taking the whole backfill with it. Every node it does not link keeps
|
||||
# its legacy columns, so nothing is lost and the next boot retries.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
device = await link_facts(db, node, facts, overwrite_scalars=True)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except (IntegrityError, OperationalError) as exc:
|
||||
skipped += 1
|
||||
logger.warning(
|
||||
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
|
||||
)
|
||||
continue
|
||||
|
||||
linked += 1
|
||||
if existing is None:
|
||||
created += 1
|
||||
logger.info(
|
||||
"Inventory backfill: node %s (%s) created device %s", node.id, row["label"], device.id
|
||||
)
|
||||
else:
|
||||
merged += 1
|
||||
logger.info(
|
||||
"Inventory backfill: node %s (%s, design %s) merged into device %s",
|
||||
node.id, row["label"], row["design_id"], device.id,
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
|
||||
@@ -0,0 +1,18 @@
|
||||
"""MAC-address normalization, shared by the scan + Proxmox persist paths.
|
||||
|
||||
Different discovery sources emit MACs in different casing/separators (ARP is
|
||||
lowercase ``bc:24:11:..``, Proxmox config is often uppercase ``BC:24:11:..``).
|
||||
Canonicalizing on write *and* on compare lets cross-source dedup match a device
|
||||
by MAC with a plain ``==`` — the join key for merging an IP-scanned row with a
|
||||
Proxmox-imported one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def normalize_mac(mac: str | None) -> str | None:
|
||||
"""Canonical MAC: lowercase, ``-`` → ``:``, stripped. Blank/None → None."""
|
||||
if not mac:
|
||||
return None
|
||||
normalized = mac.strip().lower().replace("-", ":")
|
||||
return normalized or None
|
||||
@@ -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
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Duplicate canvas nodes: the same device drawn twice on the *same* canvas.
|
||||
|
||||
A device legitimately appears on several canvases — one :class:`Node` per
|
||||
design, all pointing at one ``device_inventory`` row. Two nodes drawing that row
|
||||
on the *same* design are the corrupt case, and the only one collapsed here.
|
||||
|
||||
Identity is the device link, not the addresses: a node names the row it draws,
|
||||
so "same device" is an id comparison rather than a guess across ieee/ip/mac.
|
||||
The repair is idempotent — the oldest node stays, edges and ``parent_id``
|
||||
references are re-pointed onto it, then the extras go. No device data is lost in
|
||||
the process because none of it lives on the node any more.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Edge, Node
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def find_duplicate_node(
|
||||
db: AsyncSession,
|
||||
design_id: str | None,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
ieee: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return conflict details if this device is already drawn on ``design_id``.
|
||||
|
||||
Identity is resolved once, by the inventory row the addresses point at
|
||||
(``find_device_for``: ieee > ip > mac), and a duplicate is simply a second
|
||||
node on the same design drawing that row. Scoped to one design on purpose:
|
||||
the same device may legitimately appear on several canvases.
|
||||
|
||||
The create/approve endpoints turn this into a 409 so the UI can offer "go to
|
||||
existing" vs "add duplicate anyway".
|
||||
"""
|
||||
device = await find_device_for(db, ip=ip, mac=mac, ieee=ieee)
|
||||
if device is None:
|
||||
return None
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node)
|
||||
.where(Node.design_id == design_id, Node.device_id == device.id)
|
||||
.order_by(Node.created_at, Node.id)
|
||||
)
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
# Report which address identified the device, as the UI prints it, in the
|
||||
# same precedence find_device_for used: ieee > ip > mac.
|
||||
match: str
|
||||
value: str | None
|
||||
device_ips = {t.strip() for t in (device.ip or "").split(",") if t.strip()}
|
||||
shared_ip = next((t.strip() for t in (ip or "").split(",") if t.strip() in device_ips), None)
|
||||
if ieee and device.ieee_address == ieee:
|
||||
match, value = "ieee", ieee
|
||||
elif shared_ip:
|
||||
match, value = "ip", shared_ip
|
||||
elif mac and device.mac == mac:
|
||||
match, value = "mac", mac
|
||||
else:
|
||||
match, value = "ip", ip
|
||||
return {
|
||||
"duplicate": True,
|
||||
"existing_node_id": existing.id,
|
||||
"existing_label": device.label or existing.label,
|
||||
"match": match,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
|
||||
async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
||||
"""Merge duplicate nodes drawing the same device on the same canvas.
|
||||
|
||||
Returns the number of nodes removed. Idempotent. Nodes drawing one device on
|
||||
*different* designs are left alone — that is valid cross-canvas placement.
|
||||
The device facts live on the inventory row, so nothing has to be merged out
|
||||
of the extras: only edges and parent links are re-pointed before they go.
|
||||
Does not commit — the caller owns the transaction.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Node)
|
||||
.where(Node.device_id.is_not(None))
|
||||
.order_by(Node.device_id, Node.created_at, Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
groups: dict[tuple[str, str | None], list[Node]] = {}
|
||||
for node in rows:
|
||||
groups.setdefault((node.device_id, node.design_id), []).append(node) # type: ignore[arg-type]
|
||||
|
||||
removed = 0
|
||||
for (device_id, _design), nodes in groups.items():
|
||||
if len(nodes) < 2:
|
||||
continue
|
||||
canonical, *dups = nodes # oldest first (ordered above)
|
||||
dup_ids = {d.id for d in dups}
|
||||
|
||||
# Re-point edges + parents, then drop self-loops / duplicates.
|
||||
edges = (
|
||||
await db.execute(
|
||||
select(Edge).where(Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
for edge in edges:
|
||||
if edge.source in dup_ids:
|
||||
edge.source = canonical.id
|
||||
if edge.target in dup_ids:
|
||||
edge.target = canonical.id
|
||||
|
||||
children = (
|
||||
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
||||
).scalars().all()
|
||||
for child in children:
|
||||
child.parent_id = canonical.id
|
||||
|
||||
all_edges = (
|
||||
await db.execute(
|
||||
select(Edge).where((Edge.source == canonical.id) | (Edge.target == canonical.id))
|
||||
)
|
||||
).scalars().all()
|
||||
seen_pairs: set[tuple[str, str, str]] = set()
|
||||
for edge in all_edges:
|
||||
if edge.source == edge.target:
|
||||
await db.delete(edge)
|
||||
continue
|
||||
key = (edge.source, edge.target, edge.type)
|
||||
if key in seen_pairs:
|
||||
await db.delete(edge)
|
||||
continue
|
||||
seen_pairs.add(key)
|
||||
|
||||
await db.flush()
|
||||
for dup in dups:
|
||||
await db.delete(dup)
|
||||
removed += 1
|
||||
|
||||
logger.info(
|
||||
"Deduped device %s: merged %d duplicate node(s) into %s",
|
||||
device_id, len(dups), canonical.id,
|
||||
)
|
||||
|
||||
if removed:
|
||||
await db.flush()
|
||||
return removed
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Proxmox VE inventory service: fetch hosts + VMs + LXC via the PVE REST API.
|
||||
|
||||
Mirrors the Zigbee/Z-Wave import pipeline, but talks to the Proxmox VE REST API
|
||||
(``/api2/json``) over HTTPS with an API token instead of MQTT. It returns plain
|
||||
homelable node dicts + parent→child edge hints; DB persistence lives in the
|
||||
route layer (``app.api.routes.proxmox``).
|
||||
|
||||
Auth uses a Proxmox **API token** (never a password):
|
||||
``Authorization: PVEAPIToken=<token_id>=<secret>`` where ``token_id`` looks like
|
||||
``user@realm!tokenname``. A read-only ``PVEAuditor`` role is all that is needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.zigbee_service import merge_zigbee_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reuse the zigbee property-merge contract verbatim (same NodeProperty shape +
|
||||
# visibility-preservation rules) for re-sync updates.
|
||||
merge_proxmox_properties = merge_zigbee_properties
|
||||
|
||||
_CONNECT_TIMEOUT = 8.0
|
||||
_READ_TIMEOUT = 20.0
|
||||
_BYTES_PER_GB = 1024 ** 3
|
||||
|
||||
# net0 config line: "name=eth0,bridge=vmbr0,ip=192.168.1.5/24,gw=..."
|
||||
_LXC_IP_RE = re.compile(r"(?:^|,)ip=([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?:/\d+)?")
|
||||
# NIC MAC inside a net0 string — qemu "virtio=BC:24:11:..,bridge=.." or lxc
|
||||
# "..,hwaddr=BC:24:11:..,..". A bare 6-octet MAC match works for both forms.
|
||||
_NET_MAC_RE = re.compile(r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
|
||||
|
||||
|
||||
def _sanitize_proxmox_error(exc: BaseException) -> str:
|
||||
"""Return a generic, credential-free message for a Proxmox/HTTP error.
|
||||
|
||||
Raw httpx errors can echo the request URL and, worse, an
|
||||
``Authorization: PVEAPIToken=...=<secret>`` header in some stacks. Map known
|
||||
patterns to coarse categories so the token never reaches an API client. The
|
||||
original exception is logged at WARNING for operators.
|
||||
"""
|
||||
logger.warning("Proxmox error (sanitized for client): %r", exc)
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
code = exc.response.status_code
|
||||
if code in (401, 403):
|
||||
return "Authentication failed — check the API token and its permissions"
|
||||
if code == 404:
|
||||
return "Proxmox API path not found — is this a Proxmox VE host?"
|
||||
return f"Proxmox API returned HTTP {code}"
|
||||
raw = str(exc).lower()
|
||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||
return "Proxmox host could not be resolved"
|
||||
if "refused" in raw:
|
||||
return "Connection refused by Proxmox host"
|
||||
if "certificate" in raw or "ssl" in raw or "tls" in raw:
|
||||
return "TLS verification failed — enable 'skip TLS verify' for self-signed certs"
|
||||
if "timed out" in raw or "timeout" in raw:
|
||||
return "Connection to Proxmox host timed out"
|
||||
return "Proxmox connection failed"
|
||||
|
||||
|
||||
def _auth_header(token_id: str, token_secret: str) -> dict[str, str]:
|
||||
return {"Authorization": f"PVEAPIToken={token_id}={token_secret}"}
|
||||
|
||||
|
||||
def _gb(value: Any) -> float | None:
|
||||
"""Convert a byte count to GB (1 decimal). None/0 → None."""
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if num <= 0:
|
||||
return None
|
||||
return round(num / _BYTES_PER_GB, 1)
|
||||
|
||||
|
||||
def _int_or_none(value: Any) -> int | None:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _guest_type_to_homelable(kind: str) -> str:
|
||||
"""qemu → vm, lxc → lxc (both existing homelable node types)."""
|
||||
return "vm" if kind == "qemu" else "lxc"
|
||||
|
||||
|
||||
def _extract_qemu_ip(agent_payload: dict[str, Any] | None) -> str | None:
|
||||
"""Pull the first non-loopback IPv4 from a qemu guest-agent interfaces reply."""
|
||||
if not agent_payload:
|
||||
return None
|
||||
result = agent_payload.get("result")
|
||||
if not isinstance(result, list):
|
||||
return None
|
||||
for iface in result:
|
||||
if not isinstance(iface, dict):
|
||||
continue
|
||||
for addr in iface.get("ip-addresses") or []:
|
||||
if not isinstance(addr, dict):
|
||||
continue
|
||||
if addr.get("ip-address-type") != "ipv4":
|
||||
continue
|
||||
ip = addr.get("ip-address")
|
||||
if isinstance(ip, str) and ip and not ip.startswith("127."):
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def _extract_lxc_ip(config_payload: dict[str, Any] | None) -> str | None:
|
||||
"""Parse a static IPv4 from an LXC ``net0`` config string (skip dhcp)."""
|
||||
if not config_payload:
|
||||
return None
|
||||
net0 = config_payload.get("net0")
|
||||
if not isinstance(net0, str):
|
||||
return None
|
||||
match = _LXC_IP_RE.search(net0)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _extract_net_mac(config_payload: dict[str, Any] | None) -> str | None:
|
||||
"""Parse the NIC MAC from a qemu/lxc ``net0`` config string (normalized).
|
||||
|
||||
Works agent-free for both guest kinds and for stopped guests — the sole
|
||||
identity we can reliably cross-match against an ARP-scanned device.
|
||||
"""
|
||||
if not config_payload:
|
||||
return None
|
||||
net0 = config_payload.get("net0")
|
||||
if not isinstance(net0, str):
|
||||
return None
|
||||
match = _NET_MAC_RE.search(net0)
|
||||
return normalize_mac(match.group(1)) if match else None
|
||||
|
||||
|
||||
def _host_node(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Build a homelable ``proxmox`` host node from a ``/nodes`` entry."""
|
||||
name = raw.get("node")
|
||||
if not name:
|
||||
return None
|
||||
ieee = f"pve-node-{name}"
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": name,
|
||||
"type": "proxmox",
|
||||
"ieee_address": ieee,
|
||||
"hostname": name,
|
||||
"ip": raw.get("ip") or None,
|
||||
"status": "online" if raw.get("status") == "online" else "offline",
|
||||
"cpu_count": _int_or_none(raw.get("maxcpu")),
|
||||
"ram_gb": _gb(raw.get("maxmem")),
|
||||
"disk_gb": _gb(raw.get("maxdisk")),
|
||||
"vendor": "Proxmox VE",
|
||||
"model": None,
|
||||
"parent_ieee": None,
|
||||
}
|
||||
|
||||
|
||||
def _guest_node(
|
||||
raw: dict[str, Any], host_name: str, kind: str, ip: str | None, mac: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Build a homelable vm/lxc node from a ``/qemu`` or ``/lxc`` list entry."""
|
||||
vmid = raw.get("vmid")
|
||||
if vmid is None:
|
||||
return None
|
||||
ieee = f"pve-{host_name}-{vmid}"
|
||||
node_type = _guest_type_to_homelable(kind)
|
||||
name = raw.get("name") or f"{node_type}-{vmid}"
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": name,
|
||||
"type": node_type,
|
||||
"ieee_address": ieee,
|
||||
"hostname": name,
|
||||
"ip": ip,
|
||||
"mac": mac,
|
||||
"status": "online" if raw.get("status") == "running" else "offline",
|
||||
"cpu_count": _int_or_none(raw.get("maxcpu") or raw.get("cpus")),
|
||||
"ram_gb": _gb(raw.get("maxmem")),
|
||||
"disk_gb": _gb(raw.get("maxdisk")),
|
||||
"vendor": "Proxmox VE",
|
||||
"model": kind.upper(),
|
||||
"vmid": vmid,
|
||||
"parent_ieee": f"pve-node-{host_name}",
|
||||
}
|
||||
|
||||
|
||||
def build_proxmox_properties(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Proxmox device (specs + identity).
|
||||
|
||||
Icons match the existing hardware-property convention (``Cpu`` /
|
||||
``MemoryStick`` / ``HardDrive``). All rows default ``visible=False`` — the
|
||||
user opts in from the right panel, same as the mesh importers."""
|
||||
props: list[dict[str, Any]] = []
|
||||
vmid = node.get("vmid")
|
||||
if vmid is not None:
|
||||
props.append({"key": "VMID", "value": str(vmid), "icon": None, "visible": False})
|
||||
if node.get("model"):
|
||||
props.append({"key": "Kind", "value": node["model"], "icon": None, "visible": False})
|
||||
if node.get("cpu_count") is not None:
|
||||
props.append({"key": "CPU Cores", "value": str(node["cpu_count"]), "icon": "Cpu", "visible": False})
|
||||
if node.get("ram_gb") is not None:
|
||||
props.append({"key": "RAM", "value": f"{node['ram_gb']} GB", "icon": "MemoryStick", "visible": False})
|
||||
if node.get("disk_gb") is not None:
|
||||
props.append({"key": "Disk", "value": f"{node['disk_gb']} GB", "icon": "HardDrive", "visible": False})
|
||||
props.append({"key": "Source", "value": "Proxmox VE", "icon": None, "visible": False})
|
||||
return props
|
||||
|
||||
|
||||
def build_proxmox_cluster_links(nodes: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
||||
"""Chain host nodes (``type == 'proxmox'``) into cluster links.
|
||||
|
||||
Hosts from one import belong to the same cluster, so they are linked
|
||||
host↔host (rendered as ``cluster`` edges via left/right handles, distinct
|
||||
from the vertical host→guest ``virtual`` edges). Returns consecutive
|
||||
``(source_ieee, target_ieee)`` pairs, or ``[]`` for a single host. Mirrors
|
||||
the frontend ``buildProxmoxClusterEdges``.
|
||||
"""
|
||||
hosts = [n["ieee_address"] for n in nodes if n.get("type") == "proxmox" and n.get("ieee_address")]
|
||||
if len(hosts) < 2:
|
||||
return []
|
||||
return [(hosts[i], hosts[i + 1]) for i in range(len(hosts) - 1)]
|
||||
|
||||
|
||||
def _parse_inventory(
|
||||
hosts_raw: list[dict[str, Any]],
|
||||
guests_by_host: dict[str, list[dict[str, Any]]],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Assemble (nodes, edges) from fetched host + guest data.
|
||||
|
||||
``guests_by_host`` maps host name → list of already-normalized guest node
|
||||
dicts. Edges are one host→guest link per guest (materialized as canvas
|
||||
edges on approval, mirroring the zigbee/zwave link mechanism).
|
||||
"""
|
||||
nodes: list[dict[str, Any]] = []
|
||||
edges: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for raw in hosts_raw:
|
||||
host = _host_node(raw)
|
||||
if host is None or host["id"] in seen:
|
||||
continue
|
||||
seen.add(host["id"])
|
||||
nodes.append(host)
|
||||
|
||||
for host_name, guests in guests_by_host.items():
|
||||
host_ieee = f"pve-node-{host_name}"
|
||||
for guest in guests:
|
||||
if guest["id"] in seen:
|
||||
continue
|
||||
seen.add(guest["id"])
|
||||
nodes.append(guest)
|
||||
edges.append({"source": host_ieee, "target": guest["id"]})
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
async def _get_json(client: httpx.AsyncClient, path: str) -> Any:
|
||||
resp = await client.get(path)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("data")
|
||||
|
||||
|
||||
async def _token_has_permissions(client: httpx.AsyncClient) -> bool:
|
||||
"""True if the API token holds *any* ACL.
|
||||
|
||||
Proxmox list endpoints (``/qemu``, ``/lxc``) silently return an empty
|
||||
``200`` when the token lacks ``VM.Audit`` — indistinguishable from a host
|
||||
that genuinely has no guests. ``GET /access/permissions`` returns ``{}`` for
|
||||
a token with no ACL at all, which is the common misconfiguration (a
|
||||
privilege-separated token created without its own permission). Best-effort:
|
||||
on any error assume permissions exist so we never block a valid import.
|
||||
"""
|
||||
try:
|
||||
perms = await _get_json(client, "/access/permissions")
|
||||
except httpx.HTTPError:
|
||||
return True
|
||||
return bool(perms) if isinstance(perms, dict) else True
|
||||
|
||||
|
||||
async def fetch_proxmox_inventory(
|
||||
host: str,
|
||||
port: int,
|
||||
token_id: str,
|
||||
token_secret: str,
|
||||
verify_tls: bool = True,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Fetch hosts + VMs + LXC from a Proxmox VE host, return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
ConnectionError: transport/DNS/TLS failures (sanitized message).
|
||||
ValueError: malformed API response.
|
||||
"""
|
||||
base_url = f"https://{host}:{port}/api2/json"
|
||||
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||
guests_by_host: dict[str, list[dict[str, Any]]] = {}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=_auth_header(token_id, token_secret),
|
||||
verify=verify_tls,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
hosts_raw = await _get_json(client, "/nodes")
|
||||
if not isinstance(hosts_raw, list):
|
||||
raise ValueError("Malformed /nodes response")
|
||||
|
||||
for host_entry in hosts_raw:
|
||||
name = host_entry.get("node")
|
||||
if not name or host_entry.get("status") != "online":
|
||||
# Offline nodes can't be queried for guests; still shown as host.
|
||||
continue
|
||||
guests_by_host[name] = await _fetch_host_guests(client, name)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
||||
|
||||
return _parse_inventory(hosts_raw, guests_by_host)
|
||||
|
||||
|
||||
async def _fetch_host_guests(
|
||||
client: httpx.AsyncClient, host_name: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Fetch qemu + lxc guests for one host, resolving guest IPs best-effort."""
|
||||
guests: list[dict[str, Any]] = []
|
||||
|
||||
for kind in ("qemu", "lxc"):
|
||||
try:
|
||||
entries = await _get_json(client, f"/nodes/{host_name}/{kind}")
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Proxmox %s list failed for %s: %s", kind, host_name, exc)
|
||||
continue
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for raw in entries:
|
||||
ip, mac = await _resolve_guest_net(client, host_name, kind, raw)
|
||||
node = _guest_node(raw, host_name, kind, ip, mac)
|
||||
if node:
|
||||
guests.append(node)
|
||||
|
||||
return guests
|
||||
|
||||
|
||||
async def _resolve_guest_net(
|
||||
client: httpx.AsyncClient, host_name: str, kind: str, raw: dict[str, Any]
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Best-effort (ip, mac) for a guest. Never raises.
|
||||
|
||||
- MAC comes from the guest ``/config`` net0 line for both kinds (agent-free,
|
||||
works for stopped guests) — the cross-source dedup key.
|
||||
- IP: qemu → guest agent (running only); lxc → static net0 config.
|
||||
Partial results are returned even if a later call fails (e.g. config MAC is
|
||||
kept when the qemu agent call errors).
|
||||
"""
|
||||
vmid = raw.get("vmid")
|
||||
if vmid is None:
|
||||
return None, None
|
||||
ip: str | None = None
|
||||
mac: str | None = None
|
||||
try:
|
||||
if kind == "qemu":
|
||||
config = await _get_json(client, f"/nodes/{host_name}/qemu/{vmid}/config")
|
||||
mac = _extract_net_mac(config)
|
||||
if raw.get("status") == "running":
|
||||
data = await _get_json(
|
||||
client, f"/nodes/{host_name}/qemu/{vmid}/agent/network-get-interfaces"
|
||||
)
|
||||
ip = _extract_qemu_ip(data)
|
||||
else:
|
||||
config = await _get_json(client, f"/nodes/{host_name}/lxc/{vmid}/config")
|
||||
ip = _extract_lxc_ip(config)
|
||||
mac = _extract_net_mac(config)
|
||||
except httpx.HTTPError:
|
||||
# Guest agent not installed / container stopped / no perms. Keep whatever
|
||||
# was resolved before the failure.
|
||||
pass
|
||||
return ip, mac
|
||||
|
||||
|
||||
async def test_proxmox_connection(
|
||||
host: str,
|
||||
port: int,
|
||||
token_id: str,
|
||||
token_secret: str,
|
||||
verify_tls: bool = True,
|
||||
) -> tuple[bool, str]:
|
||||
"""Quick reachability + auth check via ``GET /version``.
|
||||
|
||||
Returns (connected, message). Never raises credentials outward.
|
||||
"""
|
||||
base_url = f"https://{host}:{port}/api2/json"
|
||||
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=_auth_header(token_id, token_secret),
|
||||
verify=verify_tls,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
data = await _get_json(client, "/version")
|
||||
has_perms = await _token_has_permissions(client)
|
||||
version = (data or {}).get("version", "?") if isinstance(data, dict) else "?"
|
||||
message = f"Connected to Proxmox VE {version}"
|
||||
if not has_perms:
|
||||
message += (
|
||||
" — warning: this API token has no permissions, so VMs and LXC "
|
||||
"will not be visible. Assign the PVEAuditor role at path '/' to the "
|
||||
"token in Proxmox (Datacenter → Permissions → API Token Permission)."
|
||||
)
|
||||
return True, message
|
||||
except httpx.HTTPError as exc:
|
||||
return False, _sanitize_proxmox_error(exc)
|
||||
except Exception as exc: # noqa: BLE001 — surface a safe message, log the rest
|
||||
logger.exception("Unexpected error during Proxmox connection test")
|
||||
return False, _sanitize_proxmox_error(exc)
|
||||
+237
-75
@@ -7,14 +7,19 @@ 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.core.config import settings
|
||||
from app.db.models import InventoryDevice, ScanRun
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||
from app.services.http_probe import probe_open_ports
|
||||
from app.services.mac_utils import normalize_mac
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +39,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 +178,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 +208,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,10 +237,18 @@ 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.
|
||||
|
||||
Two decoupled nmap passes (issue #277):
|
||||
Pass A — port discovery only (``--open``, no ``-sV``). Fast and reliable;
|
||||
its open ports are authoritative and are never discarded.
|
||||
Pass B — version detection (``-sV``) scoped to the ports Pass A found,
|
||||
bounded by ``--host-timeout``. Best-effort: if it times out or
|
||||
fails (e.g. a TLS port stalls plaintext probes), the Pass A ports
|
||||
are kept with empty banners instead of the whole host being lost.
|
||||
"""
|
||||
ip = host_dict["ip"]
|
||||
logger.info("[Phase 2] Scanning %s ...", ip)
|
||||
@@ -207,52 +257,83 @@ def _nmap_scan_single(host_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
||||
return host_dict
|
||||
|
||||
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}"
|
||||
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}"
|
||||
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
|
||||
# -sT without root but being explicit avoids edge cases.
|
||||
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||
|
||||
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
||||
nm = nmap.PortScanner()
|
||||
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
|
||||
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
|
||||
nm_disc = nmap.PortScanner()
|
||||
try:
|
||||
nm.scan(hosts=ip, arguments=scan_args)
|
||||
nm_disc.scan(hosts=ip, arguments=discovery_args)
|
||||
except Exception as exc:
|
||||
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
|
||||
logger.warning("[Phase 2] nmap discovery FAILED for %s (%s: %s) — skipping port scan",
|
||||
ip, type(exc).__name__, exc)
|
||||
return host_dict
|
||||
|
||||
all_scanned = nm.all_hosts()
|
||||
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
|
||||
if ip not in all_scanned:
|
||||
if ip not in nm_disc.all_hosts():
|
||||
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
||||
return host_dict
|
||||
|
||||
open_ports = []
|
||||
for proto in nm[ip].all_protocols():
|
||||
for port, info in nm[ip][proto].items():
|
||||
for proto in nm_disc[ip].all_protocols():
|
||||
for port, info in nm_disc[ip][proto].items():
|
||||
if info["state"] == "open":
|
||||
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
||||
open_ports.append({"port": port, "protocol": proto, "banner": banner})
|
||||
open_ports.append({"port": port, "protocol": proto, "banner": ""})
|
||||
|
||||
if open_ports:
|
||||
port_summary = ", ".join(
|
||||
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
||||
)
|
||||
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
||||
else:
|
||||
if not open_ports:
|
||||
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
||||
host_dict["open_ports"] = []
|
||||
if not host_dict["mac"]:
|
||||
host_dict["mac"] = nm_disc[ip].get("addresses", {}).get("mac")
|
||||
return host_dict
|
||||
|
||||
# --- Pass B: version detection on the discovered ports (best-effort) ---
|
||||
port_list = ",".join(str(p["port"]) for p in open_ports)
|
||||
timeout = settings.scanner_version_host_timeout
|
||||
version_args = f"{scan_type} -sV -T4 -Pn --host-timeout {timeout}s -p {port_list}"
|
||||
logger.debug("[Phase 2] %s version args: %s", ip, version_args)
|
||||
nm_ver = nmap.PortScanner()
|
||||
banners: dict[tuple[str, int], str] = {}
|
||||
ver_os = None
|
||||
ver_mac = None
|
||||
try:
|
||||
nm_ver.scan(hosts=ip, arguments=version_args)
|
||||
if ip in nm_ver.all_hosts():
|
||||
for proto in nm_ver[ip].all_protocols():
|
||||
for port, info in nm_ver[ip][proto].items():
|
||||
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
||||
if banner:
|
||||
banners[(proto, port)] = banner
|
||||
ver_os = _extract_os(nm_ver, ip)
|
||||
ver_mac = nm_ver[ip].get("addresses", {}).get("mac")
|
||||
else:
|
||||
logger.info("[Phase 2] %s — version detection returned no results, keeping %d port(s) without banners",
|
||||
ip, len(open_ports))
|
||||
except Exception as exc:
|
||||
logger.info("[Phase 2] %s — version detection failed (%s: %s), keeping %d port(s) without banners",
|
||||
ip, type(exc).__name__, exc, len(open_ports))
|
||||
|
||||
for p in open_ports:
|
||||
p["banner"] = banners.get((p["protocol"], p["port"]), "")
|
||||
|
||||
port_summary = ", ".join(
|
||||
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
||||
)
|
||||
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
||||
|
||||
host_dict["open_ports"] = open_ports
|
||||
if not host_dict["mac"]:
|
||||
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
|
||||
host_dict["os"] = _extract_os(nm, ip)
|
||||
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
|
||||
host_dict["os"] = ver_os
|
||||
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 +347,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 +365,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 +469,50 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||
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(InventoryDevice)
|
||||
.where(InventoryDevice.status != "hidden", InventoryDevice.ip.isnot(None))
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
by_ip: dict[str, list[InventoryDevice]] = {}
|
||||
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 device_inventory."""
|
||||
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 +523,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")
|
||||
select(InventoryDevice.ip).where(InventoryDevice.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,40 +546,75 @@ 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
|
||||
|
||||
norm_mac = normalize_mac(host.get("mac"))
|
||||
services = fingerprint_ports(open_ports)
|
||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||
|
||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||
# approved so a re-scan refreshes the existing row instead of spawning
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.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.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
||||
keep.mac = norm_mac or keep.mac
|
||||
keep.hostname = host.get("hostname") or keep.hostname
|
||||
keep.os = host.get("os") or keep.os
|
||||
keep.services = services
|
||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
||||
# scan guess; the importer knows the true type.
|
||||
if not (keep.ieee_address or "").startswith("pve-"):
|
||||
keep.suggested_type = suggested_type
|
||||
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
|
||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
||||
# status preserved — an approved device stays approved.
|
||||
# Stamp last_scan on the row so every canvas drawing the device
|
||||
# shows when the scanner last observed it. The row is the one
|
||||
# place that fact belongs; a node only draws it.
|
||||
keep.last_scan = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(PendingDevice(
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
mac=host.get("mac"),
|
||||
mac=norm_mac,
|
||||
hostname=host.get("hostname"),
|
||||
os=host.get("os"),
|
||||
services=services,
|
||||
suggested_type=suggested_type,
|
||||
status="pending",
|
||||
discovery_source=discovery_source,
|
||||
discovery_sources=[discovery_source],
|
||||
last_scan=datetime.now(timezone.utc),
|
||||
))
|
||||
devices_found += 1
|
||||
|
||||
@@ -463,7 +625,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
|
||||
|
||||
@@ -64,17 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
return {"status": "offline", "response_time_ms": None}
|
||||
|
||||
|
||||
def _is_ipv6(host: str) -> bool:
|
||||
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
# ping(8) -W flag units differ by OS:
|
||||
# Linux: seconds (-W 1 = 1s)
|
||||
# macOS: milliseconds (-W 1 = 1ms — fails for any RTT >1ms)
|
||||
# Windows: -w in ms (-w 1000 = 1s)
|
||||
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||
#
|
||||
# -W flag units differ by OS:
|
||||
# Linux: seconds (-W 2 = 2s)
|
||||
# macOS: milliseconds (-W 2000 = 2s)
|
||||
# Windows: -w in ms (-w 2000 = 2s)
|
||||
#
|
||||
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||
ipv6 = _is_ipv6(host)
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
family = ["-6"] if ipv6 else ["-4"]
|
||||
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||
elif sys.platform == "darwin":
|
||||
args = ["ping", "-c", "1", "-W", "1000", host]
|
||||
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
family = ["-6"] if ipv6 else []
|
||||
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
@@ -100,3 +118,125 @@ 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
|
||||
|
||||
|
||||
def _parse_override(raw: str) -> tuple[str, str | None, int | None]:
|
||||
"""Split a service `host` override into (hostname, scheme, port).
|
||||
|
||||
Mirrors the frontend `parseHostParts`: a node can serve several domains, so
|
||||
a service may carry its own host, optionally with a scheme and a port
|
||||
(`blog.example.com`, `blog.example.com:8443`, `https://blog.example.com`).
|
||||
"""
|
||||
# Like a node ip, an override may list several hosts — the first one wins,
|
||||
# same as the frontend's `splitFirstHost`.
|
||||
rest = raw.split(",")[0].strip()
|
||||
scheme: str | None = None
|
||||
for prefix in ("https://", "http://"):
|
||||
if rest.lower().startswith(prefix):
|
||||
scheme = prefix[:-3]
|
||||
rest = rest[len(prefix):]
|
||||
break
|
||||
rest = rest.split("/", 1)[0]
|
||||
|
||||
if rest.startswith("["):
|
||||
closing = rest.find("]")
|
||||
if closing == -1:
|
||||
return rest, scheme, None
|
||||
hostname = rest[1:closing]
|
||||
remainder = rest[closing + 1:]
|
||||
port = remainder[1:] if remainder.startswith(":") else ""
|
||||
return hostname, scheme, int(port) if port.isdigit() else None
|
||||
|
||||
if rest.count(":") == 1:
|
||||
hostname, _, port = rest.partition(":")
|
||||
if hostname and port.isdigit():
|
||||
return hostname, scheme, int(port)
|
||||
|
||||
return rest, scheme, None
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
override = str(svc.get("host") or "").strip()
|
||||
override_scheme: str | None = None
|
||||
override_port: int | None = None
|
||||
if override:
|
||||
host, override_scheme, override_port = _parse_override(override)
|
||||
|
||||
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
|
||||
if port is None:
|
||||
port = override_port
|
||||
|
||||
# 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 or override_scheme is not None
|
||||
if not is_web:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
scheme = override_scheme or ("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)
|
||||
# `host` rides along: several vhosts can share one port on one node, so
|
||||
# port+protocol alone no longer identifies a service on the client side.
|
||||
return {
|
||||
"port": svc.get("port"),
|
||||
"protocol": svc.get("protocol"),
|
||||
"host": svc.get("host"),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
||||
|
||||
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -20,42 +21,8 @@ _NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
"""Return a generic, credential-free message for an MQTT error.
|
||||
|
||||
The raw aiomqtt/paho error string can include the broker URI with
|
||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
||||
detail that should not leak to API clients. Map known patterns to
|
||||
coarse categories; default to a generic failure message. The original
|
||||
exception is logged at WARNING level for operator debugging.
|
||||
"""
|
||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
||||
raw = str(exc).lower()
|
||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
||||
return "Authentication failed"
|
||||
if "refused" in raw:
|
||||
return "Connection refused by broker"
|
||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||
return "Broker hostname could not be resolved"
|
||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
||||
return "TLS handshake failed"
|
||||
if "timed out" in raw or "timeout" in raw:
|
||||
return "Connection to broker timed out"
|
||||
return "MQTT connection failed"
|
||||
|
||||
|
||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
||||
ctx = ssl.create_default_context()
|
||||
if insecure:
|
||||
logger.warning(
|
||||
"MQTT TLS certificate verification is DISABLED — "
|
||||
"use only with self-signed brokers on trusted networks."
|
||||
)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
|
||||
|
||||
def build_zigbee_properties(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -6,16 +6,18 @@ aiosqlite==0.20.0
|
||||
alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
PyJWT==2.13.0
|
||||
bcrypt==4.2.1
|
||||
python-multipart==0.0.27
|
||||
authlib==1.7.2
|
||||
itsdangerous==2.2.0
|
||||
python-multipart==0.0.31
|
||||
apscheduler==3.10.4
|
||||
python-nmap==0.7.1
|
||||
pyyaml==6.0.2
|
||||
types-PyYAML==6.0.12.20240917
|
||||
websockets==13.1
|
||||
httpx==0.27.2
|
||||
zeroconf==0.149.7
|
||||
zeroconf==0.149.16
|
||||
aiomqtt==2.3.0
|
||||
|
||||
# Dev
|
||||
|
||||
@@ -44,10 +44,7 @@ async def client(db_session: AsyncSession):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(client):
|
||||
"""Returns a coroutine that logs in and returns auth headers."""
|
||||
async def _get():
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
token = res.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
return _get
|
||||
async def headers(client: AsyncClient):
|
||||
"""Authenticated Bearer headers for the default admin test user."""
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||
|
||||
@@ -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,190 @@
|
||||
"""The 3.2.0 → 3.3.0 upgrade of the `nodes` table.
|
||||
|
||||
3.3.0 moved the device facts off `nodes` onto `device_inventory` and dropped the
|
||||
columns. A real 3.2.0 database declares several of them NOT NULL with no
|
||||
server-side default (`status`, `services`, `properties`, `show_hardware`) — the
|
||||
schema here is `create_all`'s output at tag v3.2.0, not a hand-simplified copy,
|
||||
because that constraint is exactly what breaks: the 3.3.0 model no longer writes
|
||||
those columns, so if they survive the upgrade every INSERT fails with
|
||||
``NOT NULL constraint failed: nodes.status`` and approving a device is dead.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
import app.services.inventory_sync as inventory_sync
|
||||
|
||||
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
|
||||
_NODES_320 = (
|
||||
"CREATE TABLE nodes ("
|
||||
"id VARCHAR NOT NULL PRIMARY KEY, type VARCHAR NOT NULL, label VARCHAR NOT NULL, "
|
||||
"design_id VARCHAR, hostname VARCHAR, ip VARCHAR, mac VARCHAR, os VARCHAR, "
|
||||
"status VARCHAR NOT NULL, check_method VARCHAR, check_target VARCHAR, "
|
||||
"services JSON NOT NULL, notes TEXT, pos_x FLOAT NOT NULL, pos_y FLOAT NOT NULL, "
|
||||
"parent_id VARCHAR, container_mode BOOLEAN NOT NULL, custom_colors JSON, "
|
||||
"custom_icon VARCHAR, cpu_count INTEGER, cpu_model VARCHAR, ram_gb FLOAT, "
|
||||
"disk_gb FLOAT, show_hardware BOOLEAN NOT NULL, show_port_numbers BOOLEAN NOT NULL, "
|
||||
"properties JSON NOT NULL, width FLOAT, height FLOAT, "
|
||||
"bottom_handles INTEGER NOT NULL, top_handles INTEGER NOT NULL, "
|
||||
"left_handles INTEGER NOT NULL, right_handles INTEGER NOT NULL, "
|
||||
"ieee_address VARCHAR, last_seen DATETIME, last_scan DATETIME, "
|
||||
"response_time_ms INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
_DESIGNS_320 = (
|
||||
"CREATE TABLE designs (id VARCHAR NOT NULL PRIMARY KEY, name VARCHAR NOT NULL, "
|
||||
"design_type VARCHAR NOT NULL, icon VARCHAR, created_at DATETIME NOT NULL, "
|
||||
"updated_at DATETIME NOT NULL)"
|
||||
)
|
||||
|
||||
|
||||
def _node_sql(node_id: str, label: str, node_type: str, ip: str | None) -> str:
|
||||
ip_sql = f"'{ip}'" if ip else "NULL"
|
||||
return (
|
||||
"INSERT INTO nodes (id, type, label, design_id, ip, status, services, pos_x, pos_y, "
|
||||
"container_mode, show_hardware, show_port_numbers, properties, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at) VALUES "
|
||||
f"('{node_id}', '{node_type}', '{label}', 'd1', {ip_sql}, 'online', '[]', 0, 0, "
|
||||
"0, 0, 0, '[]', 1, 1, 0, 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_320(tmp_path, monkeypatch):
|
||||
"""A v3.2.0 database, with the module-global engine pointed at it."""
|
||||
db_path = tmp_path / "v320.db"
|
||||
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
monkeypatch.setattr(
|
||||
database, "AsyncSessionLocal", database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
)
|
||||
return db_path, engine
|
||||
|
||||
|
||||
async def _build_320(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DESIGNS_320)
|
||||
await conn.exec_driver_sql(_NODES_320)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES ('d1', 'Network Topology', 'network', 'dashboard', "
|
||||
"'2026-01-01 00:00:00', '2026-01-01 00:00:00')"
|
||||
)
|
||||
await conn.exec_driver_sql(_node_sql("n1", "OpnSense", "firewall", "192.168.1.1"))
|
||||
await conn.exec_driver_sql(_node_sql("n2", "NAS", "nas", "192.168.1.2"))
|
||||
await conn.exec_driver_sql(_node_sql("n3", "a note", "text", None))
|
||||
|
||||
|
||||
async def _insert_node(engine) -> None:
|
||||
"""What approve does: insert a node the way the 3.3.0 model writes one."""
|
||||
from app.db.models import Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
session.add(Node(id="new", type="firewall", label="Approved", design_id="d1"))
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_upgrade_drops_the_device_columns_and_leaves_nodes_insertable(db_320):
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
assert "ip" not in cols
|
||||
assert "device_id" in cols
|
||||
# Every device node linked; the facts moved to the inventory row.
|
||||
linked = (await conn.exec_driver_sql(
|
||||
"SELECT label, device_id FROM nodes ORDER BY id"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in linked] == ["OpnSense", "NAS", "a note"]
|
||||
assert linked[0][1] and linked[1][1]
|
||||
assert linked[2][1] is None # canvas furniture keeps no row
|
||||
ips = (await conn.exec_driver_sql(
|
||||
"SELECT ip FROM device_inventory ORDER BY ip"
|
||||
)).fetchall()
|
||||
assert [row[0] for row in ips] == ["192.168.1.1", "192.168.1.2"]
|
||||
|
||||
await _insert_node(engine)
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_node_the_backfill_cannot_link_still_leaves_nodes_insertable(db_320, monkeypatch):
|
||||
"""The regression: a failed backfill must not brick every later INSERT.
|
||||
|
||||
Before the fix, one unlinkable node kept `nodes.status` NOT NULL while the
|
||||
3.3.0 model stopped writing it, so approving *any* device raised
|
||||
``NOT NULL constraint failed: nodes.status`` (issue #351).
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
real_link_facts = inventory_sync.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if node.id == "n2":
|
||||
raise IntegrityError("boom", None, Exception("UNIQUE constraint failed"))
|
||||
return await real_link_facts(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", link_facts)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
||||
try:
|
||||
async with check.begin() as conn:
|
||||
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
||||
cols = {c[1]: c for c in info}
|
||||
# The columns stay — they are the only remaining copy of n2's facts.
|
||||
assert "status" in cols
|
||||
# But nothing is NOT NULL any more, so the model can insert again.
|
||||
assert not any(c[3] for name, c in cols.items() if name in database._LEGACY_NODE_COLUMNS)
|
||||
# Values preserved by the rebuild.
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
# The node that did link is linked; the failed one is not.
|
||||
linked = dict((await conn.exec_driver_sql(
|
||||
"SELECT id, device_id FROM nodes"
|
||||
)).fetchall())
|
||||
assert linked["n1"] is not None
|
||||
assert linked["n2"] is None
|
||||
|
||||
await _insert_node(engine)
|
||||
|
||||
# A boot where the failure persists changes nothing further: the relax is
|
||||
# a one-off, not a rebuild on every start.
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT status, ip FROM nodes WHERE id='n2'"
|
||||
)).fetchone()
|
||||
assert kept == ("online", "192.168.1.2")
|
||||
assert (await conn.exec_driver_sql(
|
||||
"SELECT COUNT(*) FROM nodes"
|
||||
)).scalar() == 4 # the three seeded, plus the one just approved
|
||||
|
||||
# A later boot retries the backfill: with the failure gone, n2 links and
|
||||
# the columns finally drop.
|
||||
monkeypatch.setattr(inventory_sync, "link_facts", real_link_facts)
|
||||
await database.init_db()
|
||||
async with check.begin() as conn:
|
||||
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
|
||||
assert "status" not in cols
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Fixtures shared across the scan test modules."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pending_device(db_session):
|
||||
import uuid
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.100",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
hostname="my-server",
|
||||
os="Linux",
|
||||
services=[{"port": 22, "name": "ssh"}],
|
||||
suggested_type="server",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db():
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.database import Base
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def two_device_inventory(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
hostname=f"host-{i}",
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(d)
|
||||
devices.append(d)
|
||||
await db_session.commit()
|
||||
for d in devices:
|
||||
await db_session.refresh(d)
|
||||
return devices
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=None,
|
||||
mac=None,
|
||||
hostname=None,
|
||||
friendly_name="bulb_1",
|
||||
services=[],
|
||||
suggested_type="zigbee_enddevice",
|
||||
device_subtype="EndDevice",
|
||||
ieee_address="0xABCDEF",
|
||||
vendor="IKEA",
|
||||
model="TRADFRI",
|
||||
lqi=180,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
|
||||
import uuid
|
||||
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
|
||||
async def _add_design(db_session, name: str) -> str:
|
||||
design = Design(id=str(uuid.uuid4()), name=name)
|
||||
db_session.add(design)
|
||||
await db_session.commit()
|
||||
return design.id
|
||||
|
||||
|
||||
def _node(design_id: str, *, ip=None, ieee=None, mac=None, device_id=None) -> Node:
|
||||
"""A canvas node. Addresses belong to the inventory row it draws, so pass
|
||||
`device_id`, or let `_node_for` mint the row from the addresses."""
|
||||
return Node(
|
||||
id=str(uuid.uuid4()), label="n", type="server", pos_x=0.0, pos_y=0.0,
|
||||
design_id=design_id, device_id=device_id,
|
||||
)
|
||||
|
||||
|
||||
async def _node_for(db_session, design_id: str, *, ip=None, ieee=None, mac=None, **device_kwargs) -> Node:
|
||||
"""A node plus the inventory row carrying its addresses, linked.
|
||||
|
||||
Reuses a row that already describes these addresses — one device is one row,
|
||||
so a test placing the same host on a second canvas gets a second node, not a
|
||||
second device.
|
||||
"""
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
device = await find_device_for(db_session, ip=ip, mac=mac, ieee=ieee)
|
||||
if device is None:
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip=ip, mac=mac, ieee_address=ieee,
|
||||
status="approved", status_live="online", **device_kwargs,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
node = _node(design_id, device_id=device.id)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
return node
|
||||
|
||||
|
||||
async def _seed_zigbee_pending_pair(db_session):
|
||||
"""Create a coordinator Node + a pending device + a link between them."""
|
||||
from app.db.models import InventoryDeviceLink, Node
|
||||
|
||||
coord_device = InventoryDevice(
|
||||
ieee_address="0xCOORD",
|
||||
friendly_name="Coordinator",
|
||||
suggested_type="zigbee_coordinator",
|
||||
status="approved",
|
||||
)
|
||||
db_session.add(coord_device)
|
||||
await db_session.flush()
|
||||
coord = Node(
|
||||
label="Coordinator",
|
||||
type="zigbee_coordinator",
|
||||
device_id=coord_device.id,
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
pending = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
device_subtype="Router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(pending)
|
||||
|
||||
db_session.add(
|
||||
InventoryDeviceLink(
|
||||
source_ieee="0xCOORD",
|
||||
target_ieee="0xR1",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return coord, pending
|
||||
@@ -0,0 +1,738 @@
|
||||
"""Approve / hide / restore / ignore / bulk device flows and conflict handling."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _node_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
|
||||
# A node with no design_id is not "on a canvas".
|
||||
await _node_for(db_session, None, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.json()[0]["canvas_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
||||
node_payload = {
|
||||
"label": "My Server",
|
||||
"type": "server",
|
||||
"ip": "192.168.1.100",
|
||||
"hostname": "my-server",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert "node_id" in data
|
||||
|
||||
# Approved devices stay in the inventory (status != "hidden") so they keep
|
||||
# showing with an "In N canvas" badge — they are no longer dropped.
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
inventory = pending_res.json()
|
||||
assert len(inventory) == 1
|
||||
assert inventory[0]["id"] == pending_device.id
|
||||
assert inventory[0]["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Approving a device whose IEEE is already on the target design prompts the
|
||||
user (409) instead of silently merging/replacing — same UX as ip/mac."""
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
# The node already on this design draws that very row.
|
||||
existing = Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{device.id}/approve",
|
||||
json={
|
||||
"label": "sensor", "type": "zigbee_enddevice",
|
||||
"status": "online", "services": [], "design_id": design.id,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
detail = res.json()["detail"]
|
||||
assert detail["duplicate"] is True
|
||||
assert detail["existing_node_id"] == existing.id
|
||||
assert detail["match"] == "ieee"
|
||||
assert detail["value"] == "0xZZZ"
|
||||
|
||||
# No second node created; device stays pending until the user decides.
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_force_creates_duplicate_ieee(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""force=True lets the user place a second card for the same IEEE."""
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
||||
status="pending", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{device.id}/approve",
|
||||
json={
|
||||
"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
||||
"services": [], "design_id": design.id, "force": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_conflicts_on_existing_ip(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""An ordinary host whose ip already sits on the target design is NOT
|
||||
silently duplicated: the approve returns 409 with the existing node so the
|
||||
UI can ask the user."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
||||
"status": "unknown", "services": [], "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
detail = res.json()["detail"]
|
||||
assert detail["duplicate"] is True
|
||||
assert detail["existing_node_id"] == existing.id
|
||||
assert detail["match"] == "ip"
|
||||
assert detail["value"] == "192.168.1.100"
|
||||
|
||||
# No node created, device left pending (user hasn't decided yet).
|
||||
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
await db_session.refresh(pending_device)
|
||||
assert pending_device.status == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_conflicts_on_existing_mac(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
# The node already on this design draws a row carrying that MAC.
|
||||
await _node_for(db_session, design, ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff")
|
||||
|
||||
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "dup", "type": "server", "ip": "192.168.1.55",
|
||||
"mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [],
|
||||
"design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
assert res.json()["detail"]["match"] == "mac"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_conflicts_on_ip_in_comma_list(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""The existing node's ip holds an IPv6 before the IPv4 the device scanned
|
||||
as. Exact-string matching missed it (issue #258); per-token matching catches
|
||||
the duplicate."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = await _node_for(db_session, design, ip="fe80::1, 192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
||||
"status": "unknown", "services": [], "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
detail = res.json()["detail"]
|
||||
assert detail["existing_node_id"] == existing.id
|
||||
assert detail["match"] == "ip"
|
||||
assert detail["value"] == "192.168.1.100"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_no_conflict_on_ip_substring(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""The ip guard must match whole addresses, not substrings: a node at
|
||||
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
await _node_for(db_session, design, ip="10.0.0.40")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "new", "type": "server", "ip": "10.0.0.4",
|
||||
"mac": None, "status": "unknown", "services": [], "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_force_creates_duplicate(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""force=True (user confirmed) bypasses the guard and creates the node."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
||||
"status": "unknown", "services": [], "design_id": design, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
||||
assert len(nodes) == 2 # duplicate deliberately created
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_allows_same_ip_on_other_design(
|
||||
client: AsyncClient, headers, db_session, pending_device
|
||||
):
|
||||
"""The guard is per-design: the same host on a different canvas is fine."""
|
||||
other = await _add_design(db_session, "Lab")
|
||||
target = await _add_design(db_session, "Home")
|
||||
await _node_for(db_session, other, ip="192.168.1.100") # exists on a DIFFERENT design
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "ok", "type": "server", "ip": "192.168.1.100",
|
||||
"status": "unknown", "services": [], "design_id": target},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_places_already_approved_on_another_design(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""A device already approved on ANOTHER canvas (global status="approved")
|
||||
must still be placeable on a new design — status is global, canvas
|
||||
membership is per-design (mirrors bulk_approve)."""
|
||||
other = await _add_design(db_session, "Other")
|
||||
target = await _add_design(db_session, "Network Topology")
|
||||
# Device is on `other` already (its global status is "approved").
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
|
||||
suggested_type="zigbee_enddevice", status="approved",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=other,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{device.id}/approve",
|
||||
json={"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
||||
"services": [], "design_id": target},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
# A node now exists on the target design too (one per canvas).
|
||||
nodes = (
|
||||
await db_session.execute(
|
||||
select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0x00158d0005292b83")
|
||||
)
|
||||
).scalars().all()
|
||||
assert {n.design_id for n in nodes} == {other, target}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device):
|
||||
"""A user-hidden device is not approvable via this endpoint."""
|
||||
pending_device.status = "hidden"
|
||||
db_session.add(pending_device)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "x", "type": "server", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||
node_payload = {
|
||||
"label": "Ghost",
|
||||
"type": "generic",
|
||||
"ip": "10.0.0.1",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/nonexistent-id/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["hidden"] is True
|
||||
|
||||
# Should no longer appear in pending
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
# Should appear in hidden
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||
# Hide first
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
# Restore
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] is True
|
||||
|
||||
# Now back in pending, gone from hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||
# Hide
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-restore",
|
||||
headers=headers,
|
||||
json={"device_ids": [pending_device.id]},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] == 1
|
||||
assert res.json()["skipped"] == 0
|
||||
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ignored"] is True
|
||||
|
||||
# Device should be gone from both pending and hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 2
|
||||
assert len(data["node_ids"]) == 2
|
||||
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||
assert len(data["device_ids"]) == 2
|
||||
assert data["skipped"] == 0
|
||||
# Approved devices stay in the inventory, now marked "approved".
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
inventory = pending_res.json()
|
||||
assert len(inventory) == 2
|
||||
assert all(d["status"] == "approved" for d in inventory)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""Regression: a device already approved (status='approved', e.g. placed on
|
||||
another canvas) must still get a node on the design being approved onto.
|
||||
|
||||
Previously bulk-approve filtered status=='pending', so selecting an
|
||||
already-approved device created no node — the user saw fewer nodes than
|
||||
they selected."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design_a = await _add_design(db_session, "Canvas A")
|
||||
design_b = await _add_design(db_session, "Canvas B")
|
||||
|
||||
# Approve both onto design A.
|
||||
res_a = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": ids, "design_id": design_a},
|
||||
headers=headers,
|
||||
)
|
||||
assert res_a.json()["approved"] == 2
|
||||
|
||||
# Re-approve the same (now status='approved') devices onto design B.
|
||||
res_b = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": ids, "design_id": design_b},
|
||||
headers=headers,
|
||||
)
|
||||
data_b = res_b.json()
|
||||
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
|
||||
assert data_b["skipped"] == 0
|
||||
|
||||
# Two nodes now exist on each design.
|
||||
from app.db.models import Node as NodeModel
|
||||
nodes_b = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
|
||||
).scalars().all()
|
||||
assert len(nodes_b) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""A device already on the target canvas (same ip) is not placed twice."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
# First device already sits on the canvas (matched by ip).
|
||||
await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": ids, "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
data = res.json()
|
||||
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
||||
assert data["skipped"] == 1
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
nodes = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
|
||||
).scalars().all()
|
||||
devices = [await db_session.get(InventoryDevice, n.device_id) for n in nodes]
|
||||
# The pre-existing node plus the one newly approved — no duplicate for .10.
|
||||
assert len(nodes) == 2
|
||||
assert sorted(d.ip for d in devices) == ["192.168.1.10", "192.168.1.11"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
|
||||
as the plain IPv4 is still recognised as already placed (issue #258)."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
await _node_for(db_session, design, ip="fe80::1, 192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": ids, "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
data = res.json()
|
||||
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
||||
assert data["skipped"] == 1
|
||||
assert data["skipped_devices"][0]["value"] == "192.168.1.10"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_reports_skipped_devices(
|
||||
client: AsyncClient, headers, db_session, two_device_inventory
|
||||
):
|
||||
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
|
||||
(with the existing node id) instead of silently dropping it."""
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": ids, "design_id": design},
|
||||
headers=headers,
|
||||
)
|
||||
data = res.json()
|
||||
assert data["approved"] == 1
|
||||
skipped = data["skipped_devices"]
|
||||
assert len(skipped) == 1
|
||||
entry = skipped[0]
|
||||
assert entry["match"] == "ip"
|
||||
assert entry["value"] == "192.168.1.10"
|
||||
assert entry["existing_node_id"] == existing.id
|
||||
assert entry["device_id"] in ids
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_copies_mac_to_node_and_properties(
|
||||
client: AsyncClient, headers, pending_device, db_session
|
||||
):
|
||||
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
# Payload intentionally omits mac — it must come from the pending device.
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.mac == "aa:bb:cc:dd:ee:ff"
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.55",
|
||||
mac="11:22:33:44:55:66",
|
||||
hostname="host-mac",
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [device.id]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.mac == "11:22:33:44:55:66"
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert mac_props == [
|
||||
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_device_inventory, db_session):
|
||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||
for n in nodes:
|
||||
device = await db_session.get(InventoryDevice, n.device_id)
|
||||
assert device is not None
|
||||
if device.ip:
|
||||
assert device.check_method == "ping", f"device {device.id} has no check_method"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.check_method == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
# Approve first device individually first
|
||||
await client.post(
|
||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
# Bulk approve both — first one is already approved (not pending), should be skipped
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 2
|
||||
assert data["skipped"] == 0
|
||||
# Should appear in hidden list
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
# Hide first device individually first
|
||||
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
||||
# Bulk hide both — first is already hidden (not pending anymore)
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_device_inventory):
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
|
||||
"""bulk-approve must place nodes on the design_id sent by the UI, not the
|
||||
first design — otherwise approved devices land on the wrong canvas."""
|
||||
first = await _add_design(db_session, "Default") # first design (fallback)
|
||||
active = await _add_design(db_session, "zwave") # the design the user is on
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-2",
|
||||
friendly_name="Living Room Plug",
|
||||
suggested_type="zwave_router",
|
||||
device_subtype="Router",
|
||||
vendor="Aeotec",
|
||||
model="ZW096",
|
||||
status="pending",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
db_session.add(dev)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [dev.id], "design_id": active},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["approved"] == 1
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-2"))
|
||||
).scalar_one()
|
||||
assert node.design_id == active
|
||||
assert node.design_id != first
|
||||
# Z-Wave device → online + Z-Wave property rows, no ICMP check. All of that
|
||||
# is the device's, so it is read off the row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.status_live == "online"
|
||||
assert device.check_method == "none"
|
||||
assert {p["key"] for p in device.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Editing an inventory row: PATCH /scan/pending/{id} and the widened create.
|
||||
|
||||
The inventory row owns the device facts, so it has to be editable on its own —
|
||||
not only through a canvas node.
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
|
||||
async def _seed(db_session, **kwargs) -> InventoryDevice:
|
||||
device = InventoryDevice(
|
||||
ip="192.168.1.10",
|
||||
mac="aa:bb:cc:11:22:33",
|
||||
hostname="nas",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
suggested_type="nas",
|
||||
status="pending",
|
||||
discovery_source="arp",
|
||||
discovery_sources=["arp"],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
**kwargs,
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_requires_auth(client: AsyncClient, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(f"/api/v1/scan/pending/{device.id}", json={"hostname": "x"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_edits_curated_fields(client: AsyncClient, headers, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"label": "Big NAS",
|
||||
"type": "nas",
|
||||
"notes": "in the garage rack",
|
||||
"cpu_count": 4,
|
||||
"cpu_model": "N5105",
|
||||
"ram_gb": 16.0,
|
||||
"disk_gb": 4000.0,
|
||||
"show_hardware": True,
|
||||
"check_method": "http",
|
||||
"check_target": "http://192.168.1.10:5000",
|
||||
"properties": [
|
||||
{"key": "Rack", "value": "B2", "icon": None, "visible": True},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": False},
|
||||
],
|
||||
"services": [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 5000, "protocol": "tcp", "service_name": "http"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["label"] == "Big NAS"
|
||||
assert data["notes"] == "in the garage rack"
|
||||
assert data["cpu_count"] == 4
|
||||
assert data["ram_gb"] == 16.0
|
||||
assert data["show_hardware"] is True
|
||||
assert data["check_method"] == "http"
|
||||
assert len(data["properties"]) == 2
|
||||
assert len(data["services"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_is_partial(client: AsyncClient, headers, db_session):
|
||||
"""Sending one field must not clear the others."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"notes": "only this"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["notes"] == "only this"
|
||||
assert data["hostname"] == "nas"
|
||||
assert data["ip"] == "192.168.1.10"
|
||||
assert data["mac"] == "aa:bb:cc:11:22:33"
|
||||
assert data["suggested_type"] == "nas"
|
||||
assert len(data["services"]) == 1
|
||||
assert data["properties"][0]["key"] == "Rack"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_normalizes_mac(client: AsyncClient, headers, db_session):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"mac": "AA-BB-CC-99-88-77"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["mac"] == "aa:bb:cc:99:88:77"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_leaves_lifecycle_alone(client: AsyncClient, headers, db_session):
|
||||
"""`status` is owned by approve/hide, not by the edit modal."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}",
|
||||
headers=headers,
|
||||
json={"label": "x", "status": "approved", "discovery_source": "manual"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["label"] == "x"
|
||||
assert data["status"] == "pending"
|
||||
assert data["discovery_source"] == "arp"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_unknown_id(client: AsyncClient, headers):
|
||||
res = await client.patch("/api/v1/scan/pending/nope", headers=headers, json={"notes": "x"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_accepts_curated_fields(client: AsyncClient, headers):
|
||||
"""A hand-made entry carries everything the modal shows, no PATCH round trip."""
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
headers=headers,
|
||||
json={
|
||||
"hostname": "patch-panel",
|
||||
"os": "n/a",
|
||||
"label": "Patch panel 24p",
|
||||
"type": "switch",
|
||||
"notes": "passive",
|
||||
"services": [{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
"friendly_name": "PP-1",
|
||||
"device_subtype": "passive",
|
||||
"check_method": "none",
|
||||
"discovery_source": "manual",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["label"] == "Patch panel 24p"
|
||||
assert data["type"] == "switch"
|
||||
assert data["os"] == "n/a"
|
||||
assert data["notes"] == "passive"
|
||||
assert data["friendly_name"] == "PP-1"
|
||||
assert data["services"][0]["port"] == 80
|
||||
assert data["status"] == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_row_defaults_are_coerced(client: AsyncClient, headers, db_session):
|
||||
"""Rows predating the 3.3.0 columns read back with defaults, not nulls."""
|
||||
device = await _seed(db_session)
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
row = next(d for d in res.json() if d["id"] == device.id)
|
||||
assert row["status_live"] == "unknown"
|
||||
assert row["show_hardware"] is False
|
||||
assert row["label"] is None
|
||||
@@ -0,0 +1,352 @@
|
||||
"""MAC-property builders and Zigbee/Z-Wave property population on approve."""
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_device_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
payload = {
|
||||
"label": "bulb_1",
|
||||
"type": "zigbee_enddevice",
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"check_method": "none",
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
keys = {p["key"]: p["value"] for p in device.properties}
|
||||
assert keys == {
|
||||
"IEEE": "0xABCDEF",
|
||||
"Vendor": "IKEA",
|
||||
"Model": "TRADFRI",
|
||||
"LQI": "180",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_zigbee_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [zigbee_pending_device.id]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
keys = {p["key"]: p["value"] for p in device.properties}
|
||||
assert keys["IEEE"] == "0xABCDEF"
|
||||
assert keys["Vendor"] == "IKEA"
|
||||
assert keys["Model"] == "TRADFRI"
|
||||
assert keys["LQI"] == "180"
|
||||
assert device.check_method == "none"
|
||||
|
||||
|
||||
def test_build_mac_property_returns_hidden_row():
|
||||
from app.api.routes.scan import build_mac_property
|
||||
|
||||
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
||||
]
|
||||
|
||||
|
||||
def test_build_mac_property_empty_when_no_mac():
|
||||
from app.api.routes.scan import build_mac_property
|
||||
|
||||
assert build_mac_property(None) == []
|
||||
assert build_mac_property("") == []
|
||||
|
||||
|
||||
def test_merge_mac_property_appends_when_absent():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
|
||||
# Existing prop preserved untouched.
|
||||
assert existing[0] in merged
|
||||
|
||||
|
||||
def test_merge_mac_property_idempotent_and_preserves_visibility():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
|
||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
||||
# No duplicate MAC row; user's visible=True choice kept.
|
||||
macs = [p for p in merged if p["key"] == "MAC"]
|
||||
assert len(macs) == 1
|
||||
assert macs[0]["visible"] is True
|
||||
|
||||
|
||||
def test_merge_mac_property_noop_without_mac():
|
||||
from app.api.routes.scan import merge_mac_property
|
||||
|
||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
||||
assert merge_mac_property(existing, None) == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_does_not_duplicate_mac_property(
|
||||
client: AsyncClient, headers, pending_device, db_session
|
||||
):
|
||||
"""If the approve payload already carries a MAC prop, don't add a second one."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={
|
||||
"label": "My Server",
|
||||
"type": "server",
|
||||
"ip": "192.168.1.100",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
"properties": [
|
||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
|
||||
).scalars().first()
|
||||
assert node is not None
|
||||
# The device facts belong to the inventory row the node draws.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
mac_props = [p for p in device.properties if p["key"] == "MAC"]
|
||||
assert len(mac_props) == 1
|
||||
# User's visibility choice is preserved.
|
||||
assert mac_props[0]["visible"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending.id}/approve",
|
||||
json={
|
||||
"label": "router_1",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert data["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
assert edges[0].source == coord.id
|
||||
assert edges[0].target == data["node_id"]
|
||||
assert edges[0].source_handle == "bottom"
|
||||
# Bare side name (canonical stored form); renders at the top like before.
|
||||
assert edges[0].target_handle == "top"
|
||||
assert edges[0].type == "iot"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_duplicate_edge(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Re-running the resolution does not create a second edge for the same pair."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
|
||||
|
||||
# Simulate a second pending row + link between same coord and a new device,
|
||||
# but keep an existing edge in place to verify dedupe also handles
|
||||
# the swapped-direction case.
|
||||
new_pending = InventoryDevice(
|
||||
ieee_address="0xR1B",
|
||||
friendly_name="r1b",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(new_pending)
|
||||
db_session.add(
|
||||
InventoryDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
|
||||
)
|
||||
assert res.json()["edges_created"] == 1 # only the new pair
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 2 # original + new, no duplicate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{a.id}/approve",
|
||||
json={
|
||||
"label": "a",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["edges_created"] == 0
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert edges == []
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # preserved for later resolution
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""First approval keeps link (other endpoint pending); second approval
|
||||
creates the edge. The link row is retained afterwards so the same pair can
|
||||
be re-approved onto another canvas — it's topology, wiped only on reimport."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
|
||||
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
|
||||
assert res.json()["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # retained for re-approval onto other canvases
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
|
||||
active = await _add_design(db_session, "zwave")
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-9",
|
||||
friendly_name="Door Sensor",
|
||||
suggested_type="zwave_enddevice",
|
||||
vendor="Aeotec",
|
||||
model="ZW120",
|
||||
status="pending",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
db_session.add(dev)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{dev.id}/approve",
|
||||
json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-9"))
|
||||
).scalar_one()
|
||||
assert node.design_id == active
|
||||
node = (await db_session.execute(select(Node))).scalars().first()
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.status_live == "online"
|
||||
assert device.check_method == "none"
|
||||
assert any(p["key"] == "Z-Wave ID" for p in device.properties)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Scan API routes: trigger, pending list, canvas-count correlation, timestamps, config."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
from tests.scan.helpers import _add_design, _node_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/scan/trigger")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
||||
with (
|
||||
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
||||
patch("app.api.routes.scan.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["ranges"] == ["192.168.1.0/24"]
|
||||
assert "id" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["ip"] == "192.168.1.100"
|
||||
assert data[0]["hostname"] == "my-server"
|
||||
# No matching node → not on any canvas.
|
||||
assert data[0]["canvas_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session, pending_device):
|
||||
# Node.ip holds several comma-separated addresses (IPv6 added first). The
|
||||
# device scanned as the plain IPv4 must still correlate (issue #258).
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
await _node_for(db_session, d1, ip="fe80::1, 192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert data[0]["canvas_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_correlates_by_mac(client, headers, db_session, pending_device):
|
||||
# Node's ip differs entirely (user edited it) but the MAC still matches:
|
||||
# the device is on the canvas (issue #258, MAC is the stable identifier).
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
await _node_for(db_session, d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff")
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert data[0]["canvas_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
|
||||
# Same IP placed on two different canvases → canvas_count == 2.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
await _node_for(db_session, d2, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
data = res.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["canvas_count"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
|
||||
suggested_type="zigbee_enddevice", services=[], status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
d1 = await _add_design(db_session, "Zigbee")
|
||||
await _node_for(db_session, d1, ieee="0x00124b001")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
by_id = {d["id"]: d for d in res.json()}
|
||||
assert by_id[device.id]["canvas_count"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
|
||||
# No matching canvas node → node_* timestamps are all null; the device still
|
||||
# carries its own discovered_at for the "Discovered" fallback on the tile.
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||
assert data["discovered_at"] is not None
|
||||
assert data["node_created_at"] is None
|
||||
assert data["node_last_scan"] is None
|
||||
assert data["node_last_modified"] is None
|
||||
assert data["node_last_seen"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
node = await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
# When the scanner last saw the device, and when it last answered, are facts
|
||||
# about the device — they live on its row, not on any one drawing of it.
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
device.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
||||
device.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||
assert data["node_created_at"] is not None # defaulted on insert
|
||||
assert data["node_last_modified"] is not None # updated_at defaulted on insert
|
||||
assert data["node_last_scan"].startswith("2026-06-01")
|
||||
assert data["node_last_seen"].startswith("2026-06-25")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
|
||||
# Two canvas nodes share the device IP: created_at takes the OLDEST,
|
||||
# last_scan takes the NEWEST.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
older = await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
newer = await _node_for(db_session, d2, ip="192.168.1.100")
|
||||
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
||||
# Both nodes draw the one row, which carries the scan observation.
|
||||
assert older.device_id == newer.device_id
|
||||
device = await db_session.get(InventoryDevice, older.device_id)
|
||||
assert device is not None
|
||||
device.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
||||
db_session.add_all([older, newer])
|
||||
await db_session.commit()
|
||||
|
||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
||||
assert data["node_created_at"].startswith("2026-01-01") # oldest node
|
||||
assert data["node_last_scan"].startswith("2026-06-01") # off the device row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_deep_scan_falls_back_to_settings():
|
||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_http_ranges = ["7000-7100"]
|
||||
mock_settings.scanner_http_probe_enabled = True
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
# Empty payload → all values come from settings defaults
|
||||
ds = _resolve_deep_scan(TriggerScanRequest())
|
||||
assert ds.http_ranges == ["7000-7100"]
|
||||
assert ds.http_probe_enabled is True
|
||||
assert ds.verify_tls is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_deep_scan_override_wins():
|
||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
ds = _resolve_deep_scan(
|
||||
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
|
||||
)
|
||||
assert ds.http_ranges == ["9000"]
|
||||
assert ds.http_probe_enabled is True
|
||||
assert ds.verify_tls is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
|
||||
captured = {}
|
||||
|
||||
async def fake_bg(run_id, ranges, deep_scan):
|
||||
captured["deep_scan"] = deep_scan
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan._background_scan", new=fake_bg),
|
||||
patch("app.api.routes.scan.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
res = await client.post(
|
||||
"/api/v1/scan/trigger",
|
||||
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert captured["deep_scan"].http_probe_enabled is True
|
||||
assert captured["deep_scan"].http_ranges == ["8000-8100"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
res = await client.post(
|
||||
"/api/v1/scan/trigger",
|
||||
json={"http_ranges": ["70000-80000"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = ["8000-8100"]
|
||||
mock_settings.scanner_http_probe_enabled = True
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
res = await client.get("/api/v1/scan/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["http_ranges"] == ["8000-8100"]
|
||||
assert data["http_probe_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
|
||||
saved = {}
|
||||
|
||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
mock_settings.scanner_http_ranges = []
|
||||
mock_settings.scanner_http_probe_enabled = False
|
||||
mock_settings.scanner_http_verify_tls = False
|
||||
mock_settings.save_overrides = lambda: saved.update(
|
||||
http_ranges=mock_settings.scanner_http_ranges,
|
||||
probe=mock_settings.scanner_http_probe_enabled,
|
||||
)
|
||||
res = await client.post(
|
||||
"/api/v1/scan/config",
|
||||
json={
|
||||
"ranges": ["192.168.1.0/24"],
|
||||
"http_ranges": ["9000-9100"],
|
||||
"http_probe_enabled": True,
|
||||
"verify_tls": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_normalizes_the_mac(client: AsyncClient, headers):
|
||||
# Dedup compares MACs by equality, so a hand-typed entry in any other
|
||||
# notation would never match the scanned row and approve would build a
|
||||
# duplicate node.
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={
|
||||
"hostname": "printer",
|
||||
"mac": "AA-BB-CC-11-22-33",
|
||||
"discovery_source": "manual",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["mac"] == "aa:bb:cc:11:22:33"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "no-mac", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["mac"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_unhides_the_row_it_merges_into(client: AsyncClient, headers, db_session):
|
||||
"""Adding a device by hand outranks an earlier hide of the same host.
|
||||
|
||||
One device is one row, hidden ones included — but merging into a hidden row
|
||||
and leaving it hidden answers 201 while nothing appears in the inventory, so
|
||||
the add reads as a no-op.
|
||||
"""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.50", status="hidden"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"ip": "192.168.1.50", "hostname": "printer", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["id"] == "d-1"
|
||||
assert res.json()["status"] == "pending"
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == ["d-1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_pending_leaves_an_approved_row_approved(client: AsyncClient, headers, db_session):
|
||||
"""Only `hidden` is lifted — a merge must not walk a device back down the
|
||||
lifecycle and re-queue something already on a canvas."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.51", status="approved"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"ip": "192.168.1.51", "hostname": "nas", "discovery_source": "manual"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
assert res.json()["status"] == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_removes_one_entry(client: AsyncClient, headers, pending_device):
|
||||
# The rack canvas drops the placeholder it created for a plate once that
|
||||
# plate is pointed at a real inventory row.
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json() == {"deleted": True}
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert listed == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_unknown_is_404(client: AsyncClient, headers):
|
||||
res = await client.delete(f"/api/v1/scan/pending/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_requires_auth(client: AsyncClient, pending_device):
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_pending_refuses_a_mounted_device(client: AsyncClient, headers, pending_device):
|
||||
# `rack_devices.device_id` is ON DELETE SET NULL: without this guard the
|
||||
# delete would strip the mount of its device instead of erroring.
|
||||
design = (
|
||||
await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Rack Room", "icon": "server", "design_type": "rack"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
saved = await client.post(
|
||||
"/api/v1/racks/save",
|
||||
json={
|
||||
"design_id": design,
|
||||
"racks": [
|
||||
{
|
||||
"id": "rack-1",
|
||||
"name": "Main",
|
||||
"u_height": 12,
|
||||
"width_standard": "19",
|
||||
"numbering": "bottom-up",
|
||||
"style": {},
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev-1",
|
||||
"rack_id": "rack-1",
|
||||
"device_id": pending_device.id,
|
||||
"label": "my-server",
|
||||
"u_start": 1,
|
||||
"u_height": 1,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "server-1u",
|
||||
"status": "unknown",
|
||||
"ports": [],
|
||||
}
|
||||
],
|
||||
"cables": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert saved.status_code == 200, saved.text
|
||||
|
||||
res = await client.delete(f"/api/v1/scan/pending/{pending_device.id}", headers=headers)
|
||||
assert res.status_code == 409
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [pending_device.id]
|
||||
@@ -0,0 +1,438 @@
|
||||
"""run_scan service persistence, _background_scan lifecycle, stop/cancel."""
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||
session rollback path must execute without a follow-on exception."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||
must NOT overwrite that terminal status with 'failed'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||
):
|
||||
from app.services.scanner import DeepScanOptions
|
||||
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
|
||||
mock_run_scan.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/runs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
# --- run_scan: re-scan updates existing pending devices ---
|
||||
|
||||
MOCK_HOST = {
|
||||
"ip": "192.168.1.50",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"hostname": "myhost.lan",
|
||||
"os": "Linux",
|
||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
assert device.suggested_type == "server"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
||||
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
|
||||
in the inventory and are surfaced with an "In N canvas" badge."""
|
||||
stale = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(stale)
|
||||
await db_session.flush()
|
||||
# A canvas already draws that device.
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
device_id=stale.id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
"""A scanned IP already drawn on a canvas refreshes the one inventory row.
|
||||
|
||||
The device is never suppressed, and never duplicated: the node draws that
|
||||
row, so a second row for the same host would split the device in two.
|
||||
"""
|
||||
existing = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.50", status="approved")
|
||||
db_session.add(existing)
|
||||
await db_session.flush()
|
||||
existing_id = existing.id
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
device_id=existing.id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].id == existing_id
|
||||
# The scan refreshed it; it did not reset the lifecycle.
|
||||
assert rows[0].status == "approved"
|
||||
assert rows[0].hostname == MOCK_HOST["hostname"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
|
||||
"""Re-scanning an already-approved device updates its row in place instead of
|
||||
spawning a fresh pending duplicate, and keeps it approved."""
|
||||
approved = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
||||
os=None, services=[], suggested_type="server", status="approved",
|
||||
)
|
||||
db_session.add(approved)
|
||||
run_id = str(uuid.uuid4())
|
||||
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "approved"
|
||||
assert rows[0].hostname == "myhost.lan" # refreshed from the scan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
|
||||
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
|
||||
row at scan start, even if the device is not re-discovered."""
|
||||
for status in ("approved", "pending", "pending"):
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
|
||||
os=None, services=[], suggested_type="server", status=status,
|
||||
))
|
||||
run_id = str(uuid.uuid4())
|
||||
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.77")
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "approved" # approved row is the one kept
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
||||
hidden = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="hidden",
|
||||
)
|
||||
db_session.add(hidden)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.ip == "192.168.1.50",
|
||||
InventoryDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/scan/fake-id/stop")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
||||
import uuid as _uuid
|
||||
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
||||
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
||||
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"stopping": True}
|
||||
# run_id added to cancel set
|
||||
assert run.id in _cancelled_runs
|
||||
# status flipped eagerly so the UI reacts without waiting for a checkpoint
|
||||
await db_session.refresh(run)
|
||||
assert run.status == "cancelled"
|
||||
assert run.finished_at is not None
|
||||
# cleanup for other tests
|
||||
_cancelled_runs.discard(run.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
||||
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
request_cancel(run_id)
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
# nmap should not have been called — cancelled before first range
|
||||
mock_nmap.assert_not_called()
|
||||
|
||||
await db_session.refresh(run)
|
||||
assert run.status == "cancelled"
|
||||
assert run.finished_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
||||
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Signal cancellation after the first CIDR scan completes
|
||||
if call_count == 1:
|
||||
request_cancel(run_id)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
||||
|
||||
assert call_count == 1 # second CIDR was skipped
|
||||
await db_session.refresh(run)
|
||||
assert run.status == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||
# Pre-existing pending device with no services
|
||||
existing = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
# Should still be only one device
|
||||
result = await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
assert len(devices) == 1
|
||||
device = devices[0]
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
+288
-3
@@ -1,5 +1,56 @@
|
||||
import time
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from authlib.integrations.base_client.errors import OAuthError
|
||||
from authlib.jose.errors import ExpiredTokenError, JoseError
|
||||
from fastapi import WebSocketDisconnect
|
||||
from httpx import AsyncClient
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
class FakeOIDCClient:
|
||||
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
|
||||
self.token = token or {}
|
||||
self.error = error
|
||||
self.redirect_uri: str | None = None
|
||||
|
||||
async def authorize_redirect(self, _request, redirect_uri: str):
|
||||
self.redirect_uri = redirect_uri
|
||||
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
|
||||
|
||||
async def authorize_access_token(self, _request):
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
return self.token
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_settings():
|
||||
from app.core.config import settings
|
||||
from app.core.security import _build_oidc_client
|
||||
|
||||
values = {
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "test-client-secret",
|
||||
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
|
||||
"oidc_scopes": "openid profile email",
|
||||
"oidc_cookie_secure": False,
|
||||
"oidc_session_expire_minutes": 60,
|
||||
"cors_origins": ["http://test"],
|
||||
}
|
||||
original = {name: getattr(settings, name) for name in values}
|
||||
for name, value in values.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
yield settings
|
||||
for name, value in original.items():
|
||||
setattr(settings, name, value)
|
||||
_build_oidc_client.cache_clear()
|
||||
|
||||
|
||||
async def test_login_success(client: AsyncClient):
|
||||
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
|
||||
assert data["token_type"] == "bearer"
|
||||
|
||||
|
||||
async def test_auth_config_defaults_to_local(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"mode": "local", "oidc_login_url": None}
|
||||
|
||||
|
||||
async def test_login_wrong_password(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
|
||||
assert res.status_code == 401
|
||||
@@ -76,7 +133,7 @@ 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
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
@@ -97,14 +154,14 @@ 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
|
||||
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)
|
||||
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
|
||||
# --- OpenID Connect ---
|
||||
|
||||
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
|
||||
res = await client.get("/api/v1/auth/config")
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {
|
||||
"mode": "oidc",
|
||||
"oidc_login_url": "/api/v1/auth/oidc/login",
|
||||
}
|
||||
|
||||
|
||||
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient()
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/login")
|
||||
assert res.status_code == 302
|
||||
assert res.headers["location"].startswith("https://idp.example/")
|
||||
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
|
||||
|
||||
|
||||
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
|
||||
from app.core.security import get_oidc_client
|
||||
|
||||
oidc_client = get_oidc_client()
|
||||
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
|
||||
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
|
||||
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
|
||||
|
||||
query = parse_qs(urlparse(authorization["url"]).query)
|
||||
assert query["response_type"] == ["code"]
|
||||
assert query["code_challenge_method"] == ["S256"]
|
||||
assert "code_challenge" in query
|
||||
assert "code_verifier" not in query
|
||||
assert query["nonce"] == [authorization["nonce"]]
|
||||
assert authorization["code_verifier"]
|
||||
|
||||
|
||||
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
callback = await client.get("/api/v1/auth/oidc/callback")
|
||||
|
||||
assert callback.status_code == 303
|
||||
assert callback.headers["location"] == "/"
|
||||
cookie = callback.headers["set-cookie"]
|
||||
assert "homelable-session=" in cookie
|
||||
assert "HttpOnly" in cookie
|
||||
assert "SameSite=lax" in cookie
|
||||
|
||||
me = await client.get("/api/v1/auth/me")
|
||||
assert me.status_code == 200
|
||||
assert me.json()["subject"] == "user-123"
|
||||
assert me.json()["display_name"] == "alice"
|
||||
assert me.json()["auth_method"] == "oidc"
|
||||
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
|
||||
assert me.json()["csrf_token"]
|
||||
|
||||
protected = await client.get("/api/v1/nodes")
|
||||
assert protected.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
|
||||
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
|
||||
token = {} if userinfo is None else {"userinfo": userinfo}
|
||||
fake_client = FakeOIDCClient(token=token)
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "sensitive detail" not in res.text
|
||||
|
||||
|
||||
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(error=ExpiredTokenError())
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
res = await client.get("/api/v1/auth/oidc/callback")
|
||||
assert res.status_code == 401
|
||||
assert res.json() == {"detail": "OIDC authentication failed"}
|
||||
assert "expired" not in res.text.lower()
|
||||
|
||||
|
||||
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
|
||||
from app.core.security import create_oidc_session_token
|
||||
|
||||
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
|
||||
fake_client = FakeOIDCClient(token={
|
||||
"userinfo": {
|
||||
"iss": "https://idp.example/application/o/homelable/",
|
||||
"sub": "user-123",
|
||||
"preferred_username": "alice",
|
||||
},
|
||||
})
|
||||
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
|
||||
await client.get("/api/v1/auth/oidc/callback")
|
||||
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
|
||||
|
||||
missing = await client.post("/api/v1/auth/logout")
|
||||
assert missing.status_code == 403
|
||||
wrong_origin = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
valid = await client.post(
|
||||
"/api/v1/auth/logout",
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert valid.status_code == 204
|
||||
assert "Max-Age=0" in valid.headers["set-cookie"]
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/auth/logout", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
|
||||
# --- WebSocket authentication ---
|
||||
|
||||
def _wait_for_connection_count(expected: int) -> None:
|
||||
from app.api.routes import status as status_routes
|
||||
|
||||
deadline = time.monotonic() + 1
|
||||
while len(status_routes._connections) != expected and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
assert len(status_routes._connections) == expected
|
||||
|
||||
|
||||
def test_local_websocket_keeps_first_message_bearer_flow():
|
||||
from app.core.security import create_access_token
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://localhost:3000"},
|
||||
) as websocket:
|
||||
websocket.send_json({"token": create_access_token("admin")})
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_websocket_rejects_untrusted_origin_before_auth():
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "https://evil.example"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
|
||||
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
|
||||
from app.main import app
|
||||
|
||||
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
|
||||
test_client = TestClient(app)
|
||||
test_client.cookies.set(oidc_session_cookie_name(), session_token)
|
||||
try:
|
||||
with test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
):
|
||||
_wait_for_connection_count(1)
|
||||
_wait_for_connection_count(0)
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
|
||||
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
|
||||
from app.main import app
|
||||
|
||||
test_client = TestClient(app)
|
||||
try:
|
||||
with (
|
||||
pytest.raises(WebSocketDisconnect) as caught,
|
||||
test_client.websocket_connect(
|
||||
"/api/v1/status/ws/status",
|
||||
headers={"Origin": "http://test"},
|
||||
) as websocket,
|
||||
):
|
||||
websocket.receive_text()
|
||||
assert caught.value.code == 1008
|
||||
finally:
|
||||
test_client.close()
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
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}
|
||||
|
||||
@@ -29,6 +22,23 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
||||
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||
|
||||
|
||||
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
||||
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
||||
# uses this to show the demo canvas only to genuinely new users.
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["initialized"] is False
|
||||
|
||||
|
||||
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
||||
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
||||
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
||||
# and must not get the demo re-seeded.
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert data["nodes"] == []
|
||||
assert data["initialized"] is True
|
||||
|
||||
|
||||
async def test_load_canvas_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/canvas")
|
||||
assert res.status_code == 401
|
||||
@@ -51,6 +61,88 @@ 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_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Router", type="router")
|
||||
n2 = node_payload(label="Switch", type="switch")
|
||||
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||
assert edge["marker_start"] == "diamond"
|
||||
assert edge["marker_end"] == "arrow"
|
||||
|
||||
|
||||
async def test_save_canvas_round_trips_line_style_and_width(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Router", type="router")
|
||||
n2 = node_payload(label="Switch", type="switch")
|
||||
e1 = edge_payload(n1["id"], n2["id"], type="wifi", line_style="dotted", width_mult=3)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||
assert edge["line_style"] == "dotted"
|
||||
assert edge["width_mult"] == 3
|
||||
|
||||
|
||||
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Router", type="router")
|
||||
n2 = node_payload(label="Switch", type="switch")
|
||||
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||
assert edge["marker_start"] == "arrow"
|
||||
assert edge["marker_end"] == "none"
|
||||
|
||||
|
||||
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(label="Router", type="router")
|
||||
n2 = node_payload(label="Switch", type="switch")
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
||||
|
||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
||||
assert edge["marker_start"] == "none"
|
||||
assert edge["marker_end"] == "none"
|
||||
|
||||
|
||||
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
||||
# Regression (#243): top/left/right_handles must persist across save+reload,
|
||||
# not just bottom_handles.
|
||||
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
|
||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||
assert node["top_handles"] == 2
|
||||
assert node["bottom_handles"] == 3
|
||||
assert node["left_handles"] == 1
|
||||
assert node["right_handles"] == 4
|
||||
|
||||
|
||||
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
|
||||
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
|
||||
n1 = node_payload(label="Srv", type="server")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
||||
|
||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||
assert node["top_handles"] == 1
|
||||
assert node["bottom_handles"] == 1
|
||||
assert node["left_handles"] == 0
|
||||
assert node["right_handles"] == 0
|
||||
|
||||
|
||||
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 +291,24 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
||||
assert canvas["nodes"][0]["show_hardware"] is False
|
||||
|
||||
|
||||
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
||||
# toggle reset on every reload.
|
||||
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(show_port_numbers=True)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is True
|
||||
|
||||
|
||||
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload()
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["nodes"][0]["show_port_numbers"] is False
|
||||
|
||||
|
||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""The pending_devices -> device_inventory rename, run at startup.
|
||||
|
||||
It is the one migration that must happen *before* `create_all`, so it gets its
|
||||
own suite: get the order wrong and every device a user ever scanned reads as
|
||||
gone, because the app looks at a freshly created empty table.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db.database import _rename_legacy_tables
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
def _legacy_ddl(table: str) -> str:
|
||||
return f"CREATE TABLE {table} (id VARCHAR PRIMARY KEY, hostname VARCHAR)"
|
||||
|
||||
|
||||
async def _engine(tmp_path, *, statements: list[str]):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
||||
async with engine.begin() as conn:
|
||||
for sql in statements:
|
||||
await conn.exec_driver_sql(sql)
|
||||
return engine
|
||||
|
||||
|
||||
async def _tables(engine) -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
).fetchall()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
async def _rename(engine) -> None:
|
||||
async with engine.begin() as conn:
|
||||
await _rename_legacy_tables(conn)
|
||||
|
||||
|
||||
async def test_renames_the_legacy_tables_keeping_their_rows(tmp_path):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("pending_device_links"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"device_inventory", "device_inventory_links"}
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql("SELECT hostname FROM device_inventory")).fetchall()
|
||||
assert [r[0] for r in rows] == ["nas"]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_rewrites_the_reference_from_rack_devices(tmp_path):
|
||||
# `rack_devices.device_id` names the inventory table. Left pointing at the
|
||||
# old name, the schema references a table that no longer exists.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
"CREATE TABLE rack_devices (id VARCHAR PRIMARY KEY, device_id VARCHAR "
|
||||
"REFERENCES pending_devices(id) ON DELETE SET NULL)",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
ddl = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT sql FROM sqlite_master WHERE name='rack_devices'"
|
||||
)
|
||||
).scalar()
|
||||
assert "device_inventory" in ddl
|
||||
assert "pending_devices" not in ddl
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_replaces_an_empty_table_left_by_an_earlier_start(tmp_path):
|
||||
# A start that ran `create_all` before this migration existed created the new
|
||||
# table empty, and the app read that one instead of the user's devices.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("device_inventory"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'nas')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"device_inventory"}
|
||||
async with engine.begin() as conn:
|
||||
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM device_inventory")).scalar()
|
||||
assert count == 1
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_leaves_two_populated_tables_alone(tmp_path):
|
||||
# Rows on both sides is not a state this migration created, and dropping
|
||||
# either one would lose data.
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
statements=[
|
||||
_legacy_ddl("pending_devices"),
|
||||
_legacy_ddl("device_inventory"),
|
||||
"INSERT INTO pending_devices (id, hostname) VALUES ('d1', 'old')",
|
||||
"INSERT INTO device_inventory (id, hostname) VALUES ('d2', 'new')",
|
||||
],
|
||||
)
|
||||
|
||||
await _rename(engine)
|
||||
|
||||
assert await _tables(engine) == {"pending_devices", "device_inventory"}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_is_a_no_op_on_a_fresh_database(tmp_path):
|
||||
engine = await _engine(tmp_path, statements=[_legacy_ddl("device_inventory")])
|
||||
|
||||
await _rename(engine)
|
||||
await _rename(engine) # And still a no-op the second time round.
|
||||
|
||||
assert await _tables(engine) == {"device_inventory"}
|
||||
await engine.dispose()
|
||||
@@ -1,15 +1,8 @@
|
||||
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}
|
||||
|
||||
@@ -100,6 +93,118 @@ async def test_list_returns_created_designs_ordered(client: AsyncClient, headers
|
||||
assert ids == [a["id"], b["id"]]
|
||||
|
||||
|
||||
# ── counts in list ──────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Counted")
|
||||
server = node_payload(label="S", type="server")
|
||||
group = node_payload(label="G", type="groupRect")
|
||||
text = node_payload(label="T", type="text")
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
d = next(x for x in listed if x["id"] == design["id"])
|
||||
assert d["node_count"] == 1
|
||||
assert d["group_count"] == 1
|
||||
assert d["text_count"] == 1
|
||||
|
||||
|
||||
async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Empty")
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
d = next(x for x in listed if x["id"] == design["id"])
|
||||
assert d["node_count"] == 0
|
||||
assert d["group_count"] == 0
|
||||
assert d["text_count"] == 0
|
||||
|
||||
|
||||
# ── copy ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_copy_requires_auth(client: AsyncClient):
|
||||
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict):
|
||||
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict):
|
||||
source = await _create(client, headers, name="Source", icon="server")
|
||||
n1 = node_payload(label="A")
|
||||
n2 = node_payload(label="B")
|
||||
e1 = edge_payload(n1["id"], n2["id"], label="link")
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
copy = res.json()
|
||||
assert copy["name"] == "Copy"
|
||||
assert copy["icon"] == "network"
|
||||
assert copy["design_type"] == "network"
|
||||
assert copy["id"] != source["id"]
|
||||
|
||||
# Copied canvas has the same shape but fresh node ids, and edge re-pointed.
|
||||
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
|
||||
assert {n["label"] for n in canvas["nodes"]} == {"A", "B"}
|
||||
copied_ids = {n["id"] for n in canvas["nodes"]}
|
||||
assert copied_ids.isdisjoint({n1["id"], n2["id"]})
|
||||
assert len(canvas["edges"]) == 1
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["source"] in copied_ids
|
||||
assert edge["target"] in copied_ids
|
||||
assert edge["label"] == "link"
|
||||
assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2}
|
||||
|
||||
|
||||
async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict):
|
||||
source = await _create(client, headers, name="Nested")
|
||||
parent = node_payload(label="P", type="proxmox", container_mode=True)
|
||||
child = node_payload(label="C", type="vm", parent_id=parent["id"])
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
|
||||
res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
copy = res.json()
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
|
||||
by_label = {n["label"]: n for n in canvas["nodes"]}
|
||||
# Child's parent_id points at the COPIED parent, not the original.
|
||||
assert by_label["C"]["parent_id"] == by_label["P"]["id"]
|
||||
assert by_label["C"]["parent_id"] != parent["id"]
|
||||
|
||||
|
||||
async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict):
|
||||
source = await _create(client, headers, name="Source")
|
||||
n1 = node_payload(label="A")
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
|
||||
src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json()
|
||||
assert len(src_canvas["nodes"]) == 1
|
||||
assert src_canvas["nodes"][0]["id"] == n1["id"]
|
||||
|
||||
|
||||
# ── update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
||||
|
||||
+205
-7
@@ -2,13 +2,6 @@ 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}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def two_nodes(client: AsyncClient, headers: dict):
|
||||
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
|
||||
@@ -91,12 +84,107 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
|
||||
assert res.json()["path_style"] == "smooth"
|
||||
|
||||
|
||||
async def test_create_edge_with_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["line_style"] == "dotted"
|
||||
assert res.json()["width_mult"] == 3
|
||||
|
||||
|
||||
async def test_update_edge_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"line_style": "dashed", "width_mult": 2}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["line_style"] == "dashed"
|
||||
assert res.json()["width_mult"] == 2
|
||||
|
||||
|
||||
async def test_create_edge_defaults_line_style_none(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["line_style"] is None
|
||||
assert res.json()["width_mult"] is None
|
||||
|
||||
|
||||
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["marker_start"] == "diamond"
|
||||
assert res.json()["marker_end"] == "arrow"
|
||||
|
||||
|
||||
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["marker_start"] == "none"
|
||||
assert res.json()["marker_end"] == "none"
|
||||
|
||||
|
||||
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["marker_end"] == "arrow"
|
||||
|
||||
|
||||
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["marker_end"] == "none"
|
||||
|
||||
|
||||
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["marker_end"] == "circle"
|
||||
assert res.json()["marker_start"] == "none"
|
||||
|
||||
|
||||
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
||||
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(
|
||||
@@ -132,3 +220,113 @@ async def test_source_and_target_handle_persist_through_update(client: AsyncClie
|
||||
assert data["source_handle"] == "cluster-right"
|
||||
assert data["target_handle"] == "cluster-left"
|
||||
assert data["label"] == "corosync"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
|
||||
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def stacked_nodes(client: AsyncClient, headers: dict):
|
||||
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
|
||||
top = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
bottom = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
return top["id"], bottom["id"]
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""Source above target -> downstream flow: exit bottom, enter top-t."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""Source below target -> upstream flow: exit top, enter bottom-t."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "top"
|
||||
assert data["target_handle"] == "bottom-t"
|
||||
|
||||
|
||||
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
|
||||
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
|
||||
src, tgt = two_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
|
||||
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
|
||||
top, bottom = stacked_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "cluster-right"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
|
||||
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
|
||||
container still resolves as downstream of a node above the container."""
|
||||
parent = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
child = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
below = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
@@ -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"},
|
||||
@@ -118,6 +124,41 @@ def test_suggest_node_type_priority_proxmox_over_server():
|
||||
assert result == "proxmox"
|
||||
|
||||
|
||||
# A port-agnostic signature: only an HTTP probe can ever match it.
|
||||
KVM_SIGNATURE = {
|
||||
"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "PiKVM",
|
||||
"service_name": "PiKVM", "icon": "monitorcog", "category": "kvm",
|
||||
"suggested_node_type": "kvm",
|
||||
}
|
||||
|
||||
|
||||
def test_suggest_node_type_kvm_from_http_title():
|
||||
# An IP-KVM answers on 443 like any web app; only the probed title identifies it.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([
|
||||
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
|
||||
])
|
||||
assert result == "kvm"
|
||||
|
||||
|
||||
def test_suggest_node_type_priority_kvm_over_server():
|
||||
# The same host also answers on 80 (→ server); the KVM signal outranks it.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([
|
||||
{"port": 80, "protocol": "tcp"},
|
||||
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
|
||||
])
|
||||
assert result == "kvm"
|
||||
|
||||
|
||||
def test_suggest_node_type_ignores_port_agnostic_sig_without_probe():
|
||||
# No http_signals (probe disabled) → port-agnostic entries can't match and the
|
||||
# port-only guess stands, unchanged from pre-probe behaviour.
|
||||
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
|
||||
result = suggest_node_type([{"port": 443, "protocol": "tcp"}])
|
||||
assert result == "server"
|
||||
|
||||
|
||||
def test_suggest_node_type_camera_from_rtsp_port():
|
||||
# RTSP port 554 → camera (via _PORT_TYPE_HINTS)
|
||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||
@@ -173,3 +214,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
|
||||
@@ -0,0 +1,855 @@
|
||||
"""The node ↔ Device Inventory link: matching, merging, and the 3.3.0 backfill.
|
||||
|
||||
The inventory row owns the device facts; a node owns how the device is drawn.
|
||||
These tests pin the rules that make one device end up as one row even when it
|
||||
was drawn on several canvases.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from app.services.inventory_sync import (
|
||||
backfill_node_devices,
|
||||
changed_facts,
|
||||
find_device_for,
|
||||
link_facts,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
)
|
||||
|
||||
|
||||
def _now(offset_minutes: int = 0) -> datetime:
|
||||
return datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=offset_minutes)
|
||||
|
||||
|
||||
async def _design(db_session, name="Net") -> str:
|
||||
design = Design(id=str(uuid.uuid4()), name=name)
|
||||
db_session.add(design)
|
||||
await db_session.commit()
|
||||
return design.id
|
||||
|
||||
|
||||
def _node(design_id: str, **kwargs) -> Node:
|
||||
payload = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"label": "n",
|
||||
"type": "server",
|
||||
"pos_x": 0.0,
|
||||
"pos_y": 0.0,
|
||||
"design_id": design_id,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
return Node(**payload)
|
||||
|
||||
|
||||
# --- pure merge rules -------------------------------------------------------
|
||||
|
||||
|
||||
class TestMergeRules:
|
||||
def test_properties_union_on_key_with_incoming_winning(self):
|
||||
base = [{"key": "Rack", "value": "A1", "icon": None, "visible": True}]
|
||||
incoming = [
|
||||
{"key": "rack", "value": "B2", "icon": None, "visible": False},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
]
|
||||
out = merge_properties(base, incoming)
|
||||
assert [p["value"] for p in out] == ["B2", "me"]
|
||||
# Existing keys keep their position — a user's ordering survives.
|
||||
assert out[0]["key"] == "Rack"
|
||||
assert out[0]["visible"] is False
|
||||
|
||||
def test_properties_keep_established_value_when_incoming_is_blank(self):
|
||||
out = merge_properties(
|
||||
[{"key": "Rack", "value": "A1", "icon": "Server", "visible": True}],
|
||||
[{"key": "Rack", "value": "", "icon": None, "visible": True}],
|
||||
)
|
||||
assert out[0]["value"] == "A1"
|
||||
assert out[0]["icon"] == "Server"
|
||||
|
||||
def test_services_union_on_port_protocol_and_name(self):
|
||||
base = [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
incoming = [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "SSH", "icon": "Terminal"},
|
||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
||||
]
|
||||
out = merge_services(base, incoming)
|
||||
assert len(out) == 2
|
||||
assert out[0]["icon"] == "Terminal"
|
||||
assert out[1]["port"] == 80
|
||||
|
||||
|
||||
class TestChangedFacts:
|
||||
"""What a save is allowed to write: its edit, not its whole snapshot."""
|
||||
|
||||
def test_an_unchanged_snapshot_writes_nothing(self):
|
||||
device = InventoryDevice(
|
||||
id="d-1",
|
||||
label="NAS",
|
||||
type="nas",
|
||||
hostname="nas.lan",
|
||||
ip="10.0.0.5",
|
||||
notes="in the garage",
|
||||
status_live="online",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
facts = {
|
||||
"label": "NAS",
|
||||
"type": "nas",
|
||||
"hostname": "nas.lan",
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "in the garage",
|
||||
"status": "online",
|
||||
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
}
|
||||
assert changed_facts(device, facts) == {}
|
||||
|
||||
def test_only_the_edited_field_survives(self):
|
||||
device = InventoryDevice(id="d-1", hostname="nas.lan", ip="10.0.0.5", notes="old")
|
||||
facts = {"hostname": "nas.lan", "ip": "10.0.0.5", "notes": "new"}
|
||||
assert changed_facts(device, facts) == {"notes": "new"}
|
||||
|
||||
def test_a_blank_incoming_value_is_not_a_change(self):
|
||||
# A blank never clears an established value, so it is not an edit either.
|
||||
device = InventoryDevice(id="d-1", hostname="nas.lan", notes="keep me")
|
||||
assert changed_facts(device, {"hostname": "", "notes": None}) == {}
|
||||
|
||||
def test_a_changed_list_is_sent_whole(self):
|
||||
device = InventoryDevice(
|
||||
id="d-1",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
)
|
||||
facts = {
|
||||
"properties": [],
|
||||
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
}
|
||||
# Replace semantics need the full list, and the untouched one stays out.
|
||||
assert changed_facts(device, facts) == {"properties": []}
|
||||
|
||||
def test_live_status_only_fills_a_row_never_checked(self):
|
||||
unknown = InventoryDevice(id="d-1", status_live="unknown")
|
||||
assert changed_facts(unknown, {"status": "online"}) == {"status": "online"}
|
||||
checked = InventoryDevice(id="d-2", status_live="offline")
|
||||
# The status checker owns reachability; a stale canvas must not reset it.
|
||||
assert changed_facts(checked, {"status": "online"}) == {}
|
||||
|
||||
|
||||
# --- matching ---------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindDeviceFor:
|
||||
@pytest.mark.asyncio
|
||||
async def test_prefers_ieee_over_ip_and_mac(self, db_session):
|
||||
by_ip = InventoryDevice(id="d-ip", ip="10.0.0.5")
|
||||
by_ieee = InventoryDevice(id="d-ieee", ieee_address="0xAAA")
|
||||
db_session.add_all([by_ip, by_ieee])
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee="0xAAA")
|
||||
assert found is not None and found.id == "d-ieee"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_one_address_out_of_a_comma_list(self, db_session):
|
||||
device = InventoryDevice(id="d-1", ip="10.0.0.5, fd00::1")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="fd00::1", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_not_match_a_longer_address_with_the_same_prefix(self, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.40"))
|
||||
await db_session.commit()
|
||||
|
||||
assert await find_device_for(db_session, ip="10.0.0.4", mac=None, ieee=None) is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_a_hidden_row_rather_than_minting_a_second(self, db_session):
|
||||
db_session.add(InventoryDevice(id="d-hidden", ip="10.0.0.5", status="hidden"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
|
||||
assert found is not None and found.id == "d-hidden"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matches_an_ieee_written_in_another_case(self, db_session):
|
||||
"""One radio, one row: `ieee_address` is UNIQUE, so a case-sensitive
|
||||
match here would mint a second row the index then refuses."""
|
||||
db_session.add(InventoryDevice(id="d-1", ieee_address="0x00124b0022334455"))
|
||||
await db_session.commit()
|
||||
|
||||
found = await find_device_for(
|
||||
db_session, ip=None, mac=None, ieee="0x00124B0022334455"
|
||||
)
|
||||
assert found is not None and found.id == "d-1"
|
||||
|
||||
|
||||
class TestIeeeCollisions:
|
||||
"""`device_inventory.ieee_address` is UNIQUE — a merge must never duplicate it."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_address_another_row_owns_is_left_where_it_is(self, db_session):
|
||||
"""The node is already linked, so identity is settled — the IEEE is not
|
||||
a reason to move it, and writing it here would violate the index."""
|
||||
design = await _design(db_session)
|
||||
db_session.add_all([
|
||||
InventoryDevice(id="d-radio", ieee_address="0xAAA"),
|
||||
InventoryDevice(id="d-host", ip="10.0.0.5"),
|
||||
])
|
||||
node = _node(design, device_id="d-host")
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.id == "d-host"
|
||||
assert device.ieee_address is None
|
||||
radio = await db_session.get(InventoryDevice, "d-radio")
|
||||
assert radio is not None and radio.ieee_address == "0xAAA"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_row_that_already_holds_it_keeps_writing_it(self, db_session):
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", ieee_address="0xAAA"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(
|
||||
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.ieee_address == "0xAAA"
|
||||
|
||||
|
||||
# --- the backfill -----------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackfill:
|
||||
"""The 3.3.0 migration path.
|
||||
|
||||
A pre-3.3.0 database still has the device columns on `nodes`; the backfill
|
||||
reads them with raw SQL (the model no longer declares them) and folds each
|
||||
node's view into an inventory row. These tests recreate that shape.
|
||||
"""
|
||||
|
||||
async def _legacy_nodes_table(self, db_session) -> None:
|
||||
"""Re-add the pre-3.3.0 device columns to `nodes`."""
|
||||
for column, sql_type in (
|
||||
("hostname", "VARCHAR"), ("ip", "VARCHAR"), ("mac", "VARCHAR"), ("os", "VARCHAR"),
|
||||
("status", "VARCHAR"), ("check_method", "VARCHAR"), ("check_target", "VARCHAR"),
|
||||
("services", "JSON"), ("notes", "TEXT"), ("cpu_count", "INTEGER"),
|
||||
("cpu_model", "VARCHAR"), ("ram_gb", "FLOAT"), ("disk_gb", "FLOAT"),
|
||||
("show_hardware", "BOOLEAN"), ("properties", "JSON"), ("ieee_address", "VARCHAR"),
|
||||
("last_seen", "DATETIME"), ("last_scan", "DATETIME"), ("response_time_ms", "INTEGER"),
|
||||
):
|
||||
await db_session.execute(text(f"ALTER TABLE nodes ADD COLUMN {column} {sql_type}"))
|
||||
await db_session.commit()
|
||||
|
||||
async def _legacy_node(self, db_session, design_id: str, *, updated_at=None, **facts) -> str:
|
||||
node_id = str(uuid.uuid4())
|
||||
columns = {
|
||||
"id": node_id,
|
||||
"label": facts.pop("label", "n"),
|
||||
"type": facts.pop("type", "server"),
|
||||
"design_id": design_id,
|
||||
"pos_x": 0.0,
|
||||
"pos_y": 0.0,
|
||||
"container_mode": 0,
|
||||
"show_port_numbers": 0,
|
||||
"bottom_handles": 1,
|
||||
"top_handles": 1,
|
||||
"left_handles": 0,
|
||||
"right_handles": 0,
|
||||
"created_at": _now(0),
|
||||
"updated_at": updated_at or _now(0),
|
||||
}
|
||||
for key in ("services", "properties"):
|
||||
if key in facts:
|
||||
facts[key] = json.dumps(facts[key])
|
||||
columns.update(facts)
|
||||
names = ", ".join(columns)
|
||||
binds = ", ".join(f":{c}" for c in columns)
|
||||
await db_session.execute(text(f"INSERT INTO nodes ({names}) VALUES ({binds})"), columns)
|
||||
await db_session.commit()
|
||||
return node_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_does_nothing_when_the_columns_are_already_gone(self, db_session):
|
||||
"""A second boot: there is no legacy data left to read."""
|
||||
design = await _design(db_session)
|
||||
db_session.add(_node(design))
|
||||
await db_session.commit()
|
||||
|
||||
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_links_a_node_to_its_existing_row(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(db_session, design, ip="10.0.0.5", label="NAS")
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 0, "merged": 1, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_a_row_for_a_node_no_scan_ever_saw(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, label="Dumb switch", type="switch", notes="under the desk"
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
|
||||
node = await db_session.get(Node, node_id)
|
||||
assert node is not None
|
||||
device = await db_session.get(InventoryDevice, node.device_id)
|
||||
assert device is not None
|
||||
assert device.label == "Dumb switch"
|
||||
assert device.type == "switch"
|
||||
assert device.notes == "under the desk"
|
||||
assert device.status == "approved"
|
||||
assert device.discovery_sources == ["canvas"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_canvases_one_device_merge_into_one_row(self, db_session):
|
||||
"""The whole point: the same host drawn twice becomes one inventory row."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design_a = await _design(db_session, "A")
|
||||
design_b = await _design(db_session, "B")
|
||||
older = await self._legacy_node(
|
||||
db_session, design_a,
|
||||
ip="10.0.0.5", label="nas-old", hostname="nas.lan", notes="older note",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
updated_at=_now(0),
|
||||
)
|
||||
newer = await self._legacy_node(
|
||||
db_session, design_b,
|
||||
ip="10.0.0.5", label="nas-new", os="TrueNAS",
|
||||
properties=[{"key": "Owner", "value": "me", "icon": None, "visible": True}],
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
updated_at=_now(30),
|
||||
)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 2
|
||||
node_a = await db_session.get(Node, older)
|
||||
node_b = await db_session.get(Node, newer)
|
||||
assert node_a is not None and node_b is not None
|
||||
assert node_a.device_id == node_b.device_id
|
||||
device = await db_session.get(InventoryDevice, node_a.device_id)
|
||||
assert device is not None
|
||||
# Scalars: most recent edit wins, blanks never wipe an established value.
|
||||
assert device.label == "nas-new"
|
||||
assert device.hostname == "nas.lan"
|
||||
assert device.os == "TrueNAS"
|
||||
assert device.notes == "older note"
|
||||
# Cumulative fields: union of both canvases.
|
||||
assert {p["key"] for p in device.properties} == {"Rack", "Owner"}
|
||||
assert {s["port"] for s in device.services} == {22, 80}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_canvas_furniture_alone(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
for kind in ("group", "groupRect", "text"):
|
||||
await self._legacy_node(db_session, design, type=kind, label=kind)
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 0
|
||||
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_a_no_op_on_a_second_run(self, db_session):
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5")
|
||||
|
||||
first = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
second = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert first["linked"] == 1
|
||||
assert second == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
# --- routes -----------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRoutesKeepTheLinkInStep:
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_a_node_joins_the_matching_row(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", os="TrueNAS"))
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
body = res.json()
|
||||
assert body["device_id"] == "d-1"
|
||||
# Hydrated from the row: the node never carried an OS.
|
||||
assert body["os"] == "TrueNAS"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creating_furniture_creates_no_row(self, client: AsyncClient, headers, db_session):
|
||||
res = await client.post(
|
||||
"/api/v1/nodes", json={"type": "groupRect", "label": "Zone"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["device_id"] is None
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editing_a_node_writes_through_to_the_row(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"hostname": "nas2.lan", "notes": "moved to the garage"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.hostname == "nas2.lan"
|
||||
assert device.notes == "moved to the garage"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patching_one_list_leaves_the_other_alone(self, client: AsyncClient, headers, db_session):
|
||||
"""Replace applies list by list — an unsent list is not blanked."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
# properties only: services must survive untouched.
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"properties": [{"key": "Owner", "value": "me", "icon": None, "visible": True}]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert [p["key"] for p in device.properties] == ["Owner"]
|
||||
assert [s["port"] for s in device.services] == [22]
|
||||
|
||||
# services only: the properties just written must survive in turn.
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}",
|
||||
json={"services": [{"port": 443, "protocol": "tcp", "service_name": "https"}]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
await db_session.refresh(device)
|
||||
assert [s["port"] for s in device.services] == [443]
|
||||
assert [p["key"] for p in device.properties] == ["Owner"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patching_a_list_empty_still_clears_it(self, client: AsyncClient, headers, db_session):
|
||||
"""An explicit empty list is a deletion, not an omission."""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.properties == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_canvas_save_does_not_revert_an_inventory_edit(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Regression: a save carries this canvas' edit, not its stale snapshot.
|
||||
|
||||
The canvas payload holds a full copy of the device, hydrated when it
|
||||
loaded. Editing the device elsewhere and then saving the still-open
|
||||
canvas — for nothing but a moved node — must not roll the row back.
|
||||
"""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
label="NAS",
|
||||
type="nas",
|
||||
notes="old note",
|
||||
status="approved",
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
|
||||
# What the canvas loaded and still holds in memory.
|
||||
stale = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
}
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [stale], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# Meanwhile, in the Device Inventory modal.
|
||||
res = await client.patch(
|
||||
"/api/v1/scan/pending/d-1",
|
||||
json={
|
||||
"notes": "moved to the loft",
|
||||
"properties": [
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True},
|
||||
{"key": "Owner", "value": "me", "icon": None, "visible": True},
|
||||
],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
# The canvas saves again — only the node position changed, so it claims
|
||||
# no device edit at all.
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [{**stale, "pos_x": 240, "changed_facts": []}],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "moved to the loft"
|
||||
assert [p["key"] for p in device.properties] == ["Rack", "Owner"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_canvas_save_still_writes_what_that_canvas_changed(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The narrowing must not cost the write-through: an edit still lands."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note"))
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
node = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
}
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [
|
||||
{
|
||||
**node,
|
||||
"notes": "edited on the canvas",
|
||||
"hostname": "nas.lan",
|
||||
"changed_facts": ["notes", "hostname"],
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "edited on the canvas"
|
||||
assert device.hostname == "nas.lan"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_edit_writes_without_dragging_the_rest_of_the_snapshot(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""One edited fact lands; the stale fields beside it stay out of the write."""
|
||||
db_session.add(
|
||||
InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note")
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
node = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "old note",
|
||||
}
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
await client.patch("/api/v1/scan/pending/d-1", json={"notes": "moved to the loft"}, headers=headers)
|
||||
|
||||
# The canvas renames the node; its `notes` copy is stale but unedited.
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [{**node, "label": "Big NAS", "changed_facts": ["label"]}],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.label == "Big NAS"
|
||||
assert device.notes == "moved to the loft"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_client_that_tracks_no_changes_still_writes_its_facts(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Backward compatible: an import or older client omits the field entirely."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", notes="old note"))
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={
|
||||
"design_id": design_id,
|
||||
"nodes": [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"status": "unknown",
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
"ip": "10.0.0.5",
|
||||
"notes": "from the import",
|
||||
}
|
||||
],
|
||||
"edges": [],
|
||||
"viewport": {},
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
await db_session.refresh(device)
|
||||
assert device.notes == "from the import"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_second_canvas_sees_the_first_canvas_edit(self, client: AsyncClient, headers, db_session):
|
||||
"""No per-node overrides: one device, one set of facts, every canvas."""
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
|
||||
await db_session.commit()
|
||||
design_a = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
|
||||
design_b = (await client.post("/api/v1/designs", json={"name": "B"}, headers=headers)).json()["id"]
|
||||
node_a = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_a, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_b, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{node_a}", json={"ip": "10.0.0.9"}, headers=headers)
|
||||
|
||||
canvas_b = (await client.get(f"/api/v1/canvas?design_id={design_b}", headers=headers)).json()
|
||||
assert canvas_b["nodes"][0]["ip"] == "10.0.0.9"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_wire_shape_the_mcp_server_reads_is_unchanged(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""`mcp/app/tools.py` reads the facts flat on the node — keep it that way.
|
||||
|
||||
The MCP server is a thin proxy over these routes: `_slim_canvas` keeps
|
||||
`NODE_KEEP`, `_slim_node_summary` keeps label/type/status, and `get_node`
|
||||
looks a node up by label. None of it knows the inventory row exists, so
|
||||
the split must not show on the wire.
|
||||
"""
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="d-1",
|
||||
ip="10.0.0.5",
|
||||
hostname="nas.lan",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
notes="in the garage",
|
||||
cpu_count=8,
|
||||
cpu_model="Xeon",
|
||||
ram_gb=32.0,
|
||||
disk_gb=4000.0,
|
||||
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
|
||||
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
|
||||
status_live="online",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
design_id = (await client.post("/api/v1/designs", json={"name": "Net"}, headers=headers)).json()["id"]
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas", "label": "NAS", "ip": "10.0.0.5",
|
||||
"design_id": design_id, "force": True,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
# mcp `_slim_canvas` NODE_KEEP, plus what `_slim_node_summary` reads.
|
||||
expected = {
|
||||
"type": "nas",
|
||||
"label": "NAS",
|
||||
"ip": "10.0.0.5",
|
||||
"hostname": "nas.lan",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"os": "TrueNAS",
|
||||
"status": "online",
|
||||
"notes": "in the garage",
|
||||
"cpu_count": 8,
|
||||
"cpu_model": "Xeon",
|
||||
"ram_gb": 32.0,
|
||||
"disk_gb": 4000.0,
|
||||
}
|
||||
for source in (
|
||||
created,
|
||||
(await client.get(f"/api/v1/nodes/{created['id']}", headers=headers)).json(),
|
||||
(await client.get("/api/v1/nodes", headers=headers)).json()[0],
|
||||
(await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0],
|
||||
# mcp `get_node` falls back to a label lookup when given no id.
|
||||
(await client.get("/api/v1/nodes?label=NAS", headers=headers)).json()[0],
|
||||
):
|
||||
assert {k: source[k] for k in expected} == expected
|
||||
assert source["services"] == [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
|
||||
assert source["properties"] == [
|
||||
{"key": "Rack", "value": "A1", "icon": None, "visible": True}
|
||||
]
|
||||
assert "parent_id" in source
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleting_a_node_keeps_the_device(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
|
||||
await db_session.commit()
|
||||
node_id = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
assert (await client.delete(f"/api/v1/nodes/{node_id}", headers=headers)).status_code == 204
|
||||
assert await db_session.get(InventoryDevice, "d-1") is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approving_a_device_links_instead_of_copying(self, client: AsyncClient, headers, db_session):
|
||||
db_session.add(
|
||||
InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", suggested_type="nas", status="pending")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/d-1/approve",
|
||||
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "status": "unknown"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = await db_session.get(Node, res.json()["node_id"])
|
||||
assert node is not None and node.device_id == "d-1"
|
||||
@@ -82,10 +82,9 @@ async def test_liveview_does_not_require_jwt(client: AsyncClient):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
async def test_liveview_returns_saved_canvas(client: AsyncClient, headers):
|
||||
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
|
||||
# Save a canvas with one node
|
||||
payload = {
|
||||
@@ -115,10 +114,9 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, 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": [],
|
||||
@@ -146,3 +144,80 @@ 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, headers):
|
||||
settings.liveview_key = "share-me"
|
||||
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, headers):
|
||||
settings.liveview_key = None
|
||||
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, headers):
|
||||
settings.liveview_key = ""
|
||||
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, headers):
|
||||
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||
settings.liveview_key = "test-key"
|
||||
|
||||
# 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,27 @@
|
||||
"""Unit tests for MAC normalization (the cross-source dedup key)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.mac_utils import normalize_mac
|
||||
|
||||
|
||||
def test_normalize_mac_lowercases_and_unifies_separators() -> None:
|
||||
assert normalize_mac("BC:24:11:AA:BB:CC") == "bc:24:11:aa:bb:cc"
|
||||
assert normalize_mac("bc-24-11-aa-bb-cc") == "bc:24:11:aa:bb:cc"
|
||||
assert normalize_mac(" BC:24:11:AA:BB:CC ") == "bc:24:11:aa:bb:cc"
|
||||
|
||||
|
||||
def test_normalize_mac_blank_is_none() -> None:
|
||||
assert normalize_mac(None) is None
|
||||
assert normalize_mac("") is None
|
||||
assert normalize_mac(" ") is None
|
||||
|
||||
|
||||
def test_add_source_unions_without_duplicates() -> None:
|
||||
assert add_source(None, "arp") == ["arp"]
|
||||
assert add_source(["arp"], "proxmox") == ["arp", "proxmox"]
|
||||
assert add_source(["arp", "proxmox"], "proxmox") == ["arp", "proxmox"]
|
||||
assert add_source(["arp"], None) == ["arp"]
|
||||
# Drops falsy members already present.
|
||||
assert add_source(["arp", ""], "proxmox") == ["arp", "proxmox"]
|
||||
@@ -0,0 +1,94 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from app.api.routes import media
|
||||
from app.core.config import settings
|
||||
|
||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def media_dir(tmp_path, monkeypatch):
|
||||
"""Point uploads at a temp folder for the duration of a test."""
|
||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
|
||||
return await client.post(
|
||||
"/api/v1/media/upload",
|
||||
files={"file": (name, data, content_type)},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_requires_auth(client, media_dir):
|
||||
res = await _upload(client, headers={})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_stores_file_and_returns_url(client, headers, media_dir):
|
||||
res = await _upload(client, headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
|
||||
# File written to disk with the server-generated name (client name ignored).
|
||||
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
|
||||
assert body["filename"] != "plan.png"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_rejects_unsupported_type(client, headers, media_dir):
|
||||
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_rejects_content_type_magic_mismatch(client, headers, media_dir):
|
||||
# Claims PNG but bytes are not a PNG.
|
||||
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_rejects_oversize(client, headers, media_dir, monkeypatch):
|
||||
monkeypatch.setattr(media, "MAX_BYTES", 8)
|
||||
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
|
||||
assert res.status_code == 413
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_serves_uploaded_file(client, headers, media_dir):
|
||||
up = await _upload(client, headers)
|
||||
res = await client.get(up.json()["url"]) # public, no auth
|
||||
assert res.status_code == 200
|
||||
assert res.content == PNG_BYTES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_rejects_bad_filename(client, media_dir):
|
||||
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_rejects_bad_filename(client, headers, media_dir):
|
||||
res = await client.delete("/api/v1/media/..%2f..%2fetc%2fpasswd", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_requires_auth_and_removes_file(client, headers, media_dir):
|
||||
up = await _upload(client, headers)
|
||||
filename = up.json()["filename"]
|
||||
|
||||
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
|
||||
assert (media_dir / filename).exists()
|
||||
|
||||
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
assert not (media_dir / filename).exists()
|
||||
assert (await client.get(up.json()["url"])).status_code == 404
|
||||
@@ -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)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe).
|
||||
|
||||
Identity is the device link now: two nodes are duplicates when they draw the
|
||||
same `device_inventory` row on the same design. The device facts live on that
|
||||
row, so nothing has to be merged out of the extras before they go — only edges
|
||||
and parent links are re-pointed.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, Edge, InventoryDevice, Node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
|
||||
|
||||
async def _design(db, name="d1"):
|
||||
d = Design(name=name)
|
||||
db.add(d)
|
||||
await db.flush()
|
||||
return d
|
||||
|
||||
|
||||
async def _device(db, ieee="0xAAA", **kwargs):
|
||||
device = InventoryDevice(ieee_address=ieee, status="approved", **kwargs)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collapses_same_device_same_design(db_session):
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session)
|
||||
keep = Node(
|
||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
||||
device_id=device.id, pos_x=100, pos_y=200,
|
||||
)
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
dup = Node(label="Sensor", type="zigbee_enddevice", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
survivor = nodes[0]
|
||||
assert survivor.id == keep.id # oldest kept
|
||||
assert survivor.pos_x == 100 # canvas position preserved
|
||||
# The device facts were never on the node, so none can be lost here.
|
||||
assert await db_session.get(InventoryDevice, device.id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preserves_same_device_across_designs(db_session):
|
||||
"""Same device on two canvases is valid — must NOT be merged."""
|
||||
d1 = await _design(db_session, "d1")
|
||||
d2 = await _design(db_session, "d2")
|
||||
device = await _device(db_session, ieee="0xBBB")
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, device_id=device.id))
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 0
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == device.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_furniture_alone(db_session):
|
||||
"""Zones and text carry no device link, so they are never duplicates."""
|
||||
d = await _design(db_session)
|
||||
for _ in range(2):
|
||||
db_session.add(Node(label="Zone", type="groupRect", design_id=d.id))
|
||||
await db_session.flush()
|
||||
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session, ieee="0xCCC")
|
||||
keep = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
||||
other = Node(label="B", type="server", design_id=d.id)
|
||||
db_session.add_all([keep, other])
|
||||
await db_session.flush()
|
||||
dup = Node(label="A", type="server", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
|
||||
# keep<->other and dup<->other (parallel after repoint), plus dup<->keep (self-loop).
|
||||
db_session.add_all([
|
||||
Edge(source=keep.id, target=other.id, type="ethernet", design_id=d.id),
|
||||
Edge(source=dup.id, target=other.id, type="ethernet", design_id=d.id),
|
||||
Edge(source=dup.id, target=keep.id, type="ethernet", design_id=d.id),
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
# self-loop dropped, parallel edge collapsed -> a single keep<->other edge
|
||||
assert len(edges) == 1
|
||||
e = edges[0]
|
||||
assert {e.source, e.target} == {keep.id, other.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_child_parent(db_session):
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session, ieee="0xDDD")
|
||||
keep = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
||||
db_session.add(keep)
|
||||
await db_session.flush()
|
||||
dup = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
|
||||
db_session.add(dup)
|
||||
await db_session.flush()
|
||||
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
|
||||
db_session.add(child)
|
||||
await db_session.flush()
|
||||
|
||||
await dedupe_nodes_by_device(db_session)
|
||||
await db_session.refresh(child)
|
||||
assert child.parent_id == keep.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_noop_when_unique(db_session):
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session, ieee="0xEEE")
|
||||
db_session.add(Node(label="X", type="server", design_id=d.id, device_id=device.id))
|
||||
await db_session.flush()
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
+202
-8
@@ -1,14 +1,6 @@
|
||||
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}"}
|
||||
|
||||
|
||||
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
@@ -67,6 +59,91 @@ 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_rejects_duplicate_ip_on_same_design(client: AsyncClient, headers: dict):
|
||||
# A second node with the same ip on the same design is a silent duplicate —
|
||||
# scripts/MCP clients get 409 with the existing node id instead. (#260)
|
||||
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
|
||||
design_id = design.json()["id"]
|
||||
first = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert first.status_code == 201
|
||||
existing_id = first.json()["id"]
|
||||
|
||||
dup = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "srv-again", "ip": "192.168.1.5", "design_id": design_id},
|
||||
headers=headers,
|
||||
)
|
||||
assert dup.status_code == 409
|
||||
detail = dup.json()["detail"]
|
||||
assert detail["duplicate"] is True
|
||||
assert detail["existing_node_id"] == existing_id
|
||||
assert detail["match"] == "ip"
|
||||
|
||||
|
||||
async def test_create_node_force_bypasses_duplicate_guard(client: AsyncClient, headers: dict):
|
||||
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
|
||||
design_id = design.json()["id"]
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
|
||||
headers=headers,
|
||||
)
|
||||
forced = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id, "force": True},
|
||||
headers=headers,
|
||||
)
|
||||
assert forced.status_code == 201
|
||||
|
||||
|
||||
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
|
||||
@@ -209,3 +286,120 @@ async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["properties"] == props
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-positioning (issue #265): omitting pos_x/pos_y snaps the node to the
|
||||
# first free 200x100 grid slot instead of stacking everything at (0, 0).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_create_node_auto_positions_first_at_origin(client: AsyncClient, headers: dict):
|
||||
"""First root node with no coordinates lands at (0, 0)."""
|
||||
res = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_auto_position_avoids_collision(client: AsyncClient, headers: dict):
|
||||
"""A second auto-placed root node takes the next free grid cell, not (0, 0)."""
|
||||
first = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert (first.json()["pos_x"], first.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
second = await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "B", "status": "unknown"}, headers=headers
|
||||
)
|
||||
assert second.status_code == 201
|
||||
# Next free cell in row 0 is column 1 -> x = 1 * 200.
|
||||
assert (second.json()["pos_x"], second.json()["pos_y"]) == (200.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_child_defaults_to_parent_origin(client: AsyncClient, headers: dict):
|
||||
"""A child node (parent_id set) with no coordinates defaults to (0, 0) relative to its parent."""
|
||||
parent = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "vm", "label": "VM1", "status": "online", "parent_id": parent["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_create_node_explicit_position_preserved(client: AsyncClient, headers: dict):
|
||||
"""Explicit coordinates are honored, never auto-placed."""
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "A", "status": "unknown", "pos_x": 512.0, "pos_y": 384.0},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
data = res.json()
|
||||
assert (data["pos_x"], data["pos_y"]) == (512.0, 384.0)
|
||||
|
||||
|
||||
async def test_create_node_explicit_zero_position_preserved(client: AsyncClient, headers: dict):
|
||||
"""pos=0 is an explicit value, not 'omitted' — auto-position must not treat 0 as None."""
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
||||
)
|
||||
# Second node explicitly pinned to (0, 0) even though the cell is taken.
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "B", "status": "unknown", "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_exact(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=Proxmox", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_substring(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve1-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "pve2-node", "status": "unknown"}, headers=headers)
|
||||
await client.post("/api/v1/nodes", json={"type": "router", "label": "Router", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=pve", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert sorted(n["label"] for n in res.json()) == ["pve1-node", "pve2-node"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_case_insensitive(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=PROXMOX", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert [n["label"] for n in res.json()] == ["Proxmox"]
|
||||
|
||||
|
||||
async def test_list_nodes_filters_by_label_no_match(client: AsyncClient, headers: dict):
|
||||
await client.post("/api/v1/nodes", json={"type": "server", "label": "Proxmox", "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes?label=nonexistent", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, headers: dict):
|
||||
"""Omitting label is fully backward compatible with the old unfiltered behavior."""
|
||||
for label in ("A", "B", "C"):
|
||||
await client.post("/api/v1/nodes", json={"type": "generic", "label": label, "status": "unknown"}, headers=headers)
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 3
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
"""API + persistence tests for /api/v1/proxmox/*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.proxmox import (
|
||||
_background_proxmox_import,
|
||||
_guest_visibility_advisory,
|
||||
_persist_pending_import,
|
||||
)
|
||||
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
|
||||
from app.core.config import settings
|
||||
from app.db.models import Design, Edge, InventoryDevice, InventoryDeviceLink, Node
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_env_token():
|
||||
"""Ensure a clean token state per test; restore afterwards."""
|
||||
tid, sec = settings.proxmox_token_id, settings.proxmox_token_secret
|
||||
settings.proxmox_token_id = ""
|
||||
settings.proxmox_token_secret = ""
|
||||
yield
|
||||
settings.proxmox_token_id, settings.proxmox_token_secret = tid, sec
|
||||
|
||||
|
||||
def _host_node() -> dict:
|
||||
return {
|
||||
"id": "pve-node-pve1", "label": "pve1", "type": "proxmox",
|
||||
"ieee_address": "pve-node-pve1", "hostname": "pve1", "ip": None,
|
||||
"status": "online", "cpu_count": 8, "ram_gb": 16.0, "disk_gb": 500.0,
|
||||
"vendor": "Proxmox VE", "model": None, "parent_ieee": None,
|
||||
}
|
||||
|
||||
|
||||
def _guest_node(vmid: int, ip: str | None, status: str = "online", mac: str | None = None) -> dict:
|
||||
return {
|
||||
"id": f"pve-pve1-{vmid}", "label": f"vm{vmid}", "type": "vm",
|
||||
"ieee_address": f"pve-pve1-{vmid}", "hostname": f"vm{vmid}", "ip": ip,
|
||||
"mac": mac,
|
||||
"status": status, "cpu_count": 2, "ram_gb": 4.0, "disk_gb": 32.0,
|
||||
"vendor": "Proxmox VE", "model": "QEMU", "vmid": vmid,
|
||||
"parent_ieee": "pve-node-pve1",
|
||||
}
|
||||
|
||||
|
||||
# --- endpoints -------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_uses_body_token(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.proxmox.test_proxmox_connection", new=AsyncMock(return_value=(True, "ok"))):
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/test-connection",
|
||||
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["connected"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_token_is_rejected(client: AsyncClient, headers: dict) -> None:
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/test-connection",
|
||||
json={"host": "pve", "port": 8006},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_pending_creates_scan_run(client: AsyncClient, headers: dict) -> None:
|
||||
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/import-pending",
|
||||
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "proxmox"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_creates_scan_run(client: AsyncClient, headers: dict) -> None:
|
||||
settings.proxmox_host = "pve"
|
||||
settings.proxmox_token_id = "u@pam!t"
|
||||
settings.proxmox_token_secret = "s"
|
||||
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
|
||||
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "proxmox"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_rejected_without_token(client: AsyncClient, headers: dict) -> None:
|
||||
settings.proxmox_host = "pve"
|
||||
# _clear_env_token leaves token empty
|
||||
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/proxmox/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/proxmox/import-pending", json={"host": "pve"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_omits_token(client: AsyncClient, headers: dict) -> None:
|
||||
settings.proxmox_token_id = "u@pam!t"
|
||||
settings.proxmox_token_secret = "supersecret"
|
||||
res = await client.get("/api/v1/proxmox/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.text
|
||||
assert "supersecret" not in body
|
||||
assert res.json()["token_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_sync_without_token_rejected(client: AsyncClient, headers: dict) -> None:
|
||||
# _clear_env_token leaves token empty → enabling auto-sync is rejected.
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/config",
|
||||
json={"sync_enabled": True, "sync_interval": 600},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_config_persists_only_sync_fields(client: AsyncClient, headers: dict) -> None:
|
||||
"""Connection config is env-only: even if a client sends host/port/verify,
|
||||
the endpoint ignores them and persists only the sync activation."""
|
||||
settings.proxmox_host = "pve"
|
||||
settings.proxmox_token_id = "u@pam!t"
|
||||
settings.proxmox_token_secret = "s"
|
||||
saved: dict = {}
|
||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
||||
host=self.proxmox_host, enabled=self.proxmox_sync_enabled, interval=self.proxmox_sync_interval
|
||||
)), patch("app.api.routes.proxmox.set_proxmox_sync_enabled"), \
|
||||
patch("app.api.routes.proxmox.reschedule_proxmox_sync"):
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/config",
|
||||
json={"host": "attacker", "port": 1, "verify_tls": False, "sync_enabled": True, "sync_interval": 900},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
# host untouched by the request body; sync activation applied.
|
||||
assert settings.proxmox_host == "pve"
|
||||
assert saved == {"host": "pve", "enabled": True, "interval": 900}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_import_broadcasts_refresh() -> None:
|
||||
"""After persisting, the import emits a scan update so an open inventory
|
||||
reloads without a manual refresh (same signal the IP scan uses)."""
|
||||
fake_db = AsyncMock()
|
||||
fake_db.get = AsyncMock(return_value=None) # no ScanRun row → skip status update
|
||||
cm = AsyncMock()
|
||||
cm.__aenter__.return_value = fake_db
|
||||
cm.__aexit__.return_value = False
|
||||
|
||||
with patch("app.api.routes.proxmox.AsyncSessionLocal", MagicMock(return_value=cm)), \
|
||||
patch("app.api.routes.proxmox.fetch_proxmox_inventory", new=AsyncMock(return_value=([], []))), \
|
||||
patch("app.api.routes.proxmox._persist_pending_import",
|
||||
new=AsyncMock(return_value=SimpleNamespace(device_count=3))), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new=AsyncMock()) as bcast:
|
||||
await _background_proxmox_import("run1", "h", 8006, "u@pam!t", "s", True)
|
||||
|
||||
bcast.assert_awaited_once()
|
||||
assert bcast.await_args.kwargs["devices_found"] == 3
|
||||
|
||||
|
||||
# --- guest-visibility advisory ---------------------------------------------
|
||||
|
||||
def test_advisory_when_hosts_only() -> None:
|
||||
msg = _guest_visibility_advisory([_host_node()])
|
||||
assert msg is not None
|
||||
assert "PVEAuditor" in msg
|
||||
|
||||
|
||||
def test_no_advisory_when_guests_present() -> None:
|
||||
assert _guest_visibility_advisory([_host_node(), _guest_node(101, "10.0.0.5")]) is None
|
||||
|
||||
|
||||
def test_no_advisory_when_nothing_imported() -> None:
|
||||
assert _guest_visibility_advisory([]) is None
|
||||
|
||||
|
||||
# --- persistence / dedupe --------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_creates_pending(db_session) -> None:
|
||||
nodes = [_host_node(), _guest_node(101, "10.0.0.5")]
|
||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
result = await _persist_pending_import(db_session, nodes, edges)
|
||||
assert result.pending_created == 2
|
||||
assert result.links_recorded == 1
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
|
||||
# Specs carried as properties.
|
||||
vm = next(r for r in rows if r.suggested_type == "vm")
|
||||
assert any(p["key"] == "CPU Cores" for p in vm.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
|
||||
# A device previously found by an IP scan (no ieee, no specs).
|
||||
# The scanned host: one inventory row, drawn by one node.
|
||||
scanned_device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", suggested_type="generic",
|
||||
status="approved", discovery_source="arp", discovery_sources=["arp"],
|
||||
)
|
||||
db_session.add(scanned_device)
|
||||
await db_session.flush()
|
||||
scanned = Node(
|
||||
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
|
||||
device_id=scanned_device.id, pos_x=0, pos_y=0,
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
|
||||
# No duplicate node, and no second row: the import merged into the row the
|
||||
# node already draws.
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
|
||||
assert merged is not None
|
||||
assert merged.ieee_address == "pve-pve1-101"
|
||||
assert merged.cpu_count == 2
|
||||
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
|
||||
# Still approved — it is on a canvas.
|
||||
assert merged.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
# Device previously found by an IP scan: arp source, MAC known, no ieee.
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
|
||||
suggested_type="generic", status="pending",
|
||||
discovery_source="arp", discovery_sources=["arp"],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
# Proxmox import of the same box (stopped VM → no IP) but same NIC MAC in a
|
||||
# different casing. Must merge, not duplicate.
|
||||
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
|
||||
assert row.suggested_type == "vm" # kept proxmox type
|
||||
assert set(row.discovery_sources) == {"arp", "proxmox"} # shows in both filters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
|
||||
# Legacy inventory row from an old IP scan, before discovery_source(s) were
|
||||
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
suggested_type="lxc", status="pending",
|
||||
discovery_source=None, discovery_sources=[],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(
|
||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
||||
)
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
# The IP tag must survive the proxmox merge even with no recorded origin.
|
||||
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -> None:
|
||||
# The immich case: an on-canvas node from an old scan, with a legacy
|
||||
# inventory row (NULL source). Merge must keep the IP tag on the row.
|
||||
inv = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
suggested_type="lxc", status="approved",
|
||||
discovery_source=None, discovery_sources=[],
|
||||
)
|
||||
db_session.add(inv)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
id=str(uuid.uuid4()), type="lxc", label="immich",
|
||||
device_id=inv.id, pos_x=0, pos_y=0,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(
|
||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
||||
)
|
||||
|
||||
inv_row = (await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.mac == "bc:24:11:6c:96:52")
|
||||
)).scalar_one()
|
||||
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) -> None:
|
||||
# A guest first seen via Proxmox (agent IP, pve ieee) must NOT gain a spurious
|
||||
# IP tag on re-sync — it was never IP-scanned.
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
|
||||
row = (await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101")
|
||||
)).scalar_one()
|
||||
assert set(row.discovery_sources) == {"proxmox"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
|
||||
# A scanned canvas node with a MAC but no IP recorded for the guest.
|
||||
scanned_device = InventoryDevice(
|
||||
id=str(uuid.uuid4()), mac="bc:24:11:aa:bb:cc", suggested_type="generic",
|
||||
status="approved", discovery_source="arp", discovery_sources=["arp"],
|
||||
)
|
||||
db_session.add(scanned_device)
|
||||
await db_session.flush()
|
||||
scanned = Node(
|
||||
id=str(uuid.uuid4()), type="generic", label="box",
|
||||
device_id=scanned_device.id, pos_x=0, pos_y=0,
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
|
||||
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert len(nodes) == 1 # no duplicate node
|
||||
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
|
||||
assert merged is not None
|
||||
assert merged.ieee_address == "pve-pve1-101"
|
||||
assert merged.mac == "bc:24:11:aa:bb:cc"
|
||||
assert merged.cpu_count == 2 # specs backfilled
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_resync_updates_in_place(db_session) -> None:
|
||||
nodes = [_guest_node(101, "10.0.0.5")]
|
||||
await _persist_pending_import(db_session, nodes, [])
|
||||
# Second sync: same device, new IP. Should update, not duplicate.
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
|
||||
rows = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].ip == "10.0.0.9"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
|
||||
suggested_type="vm", status="hidden", discovery_source="proxmox",
|
||||
))
|
||||
await db_session.commit()
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
row = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
assert row.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
|
||||
# Legacy row: properties column NULL (added by migration on older DBs).
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
|
||||
status="pending", discovery_source="arp",
|
||||
)
|
||||
dev.properties = None
|
||||
db_session.add(dev)
|
||||
await db_session.commit()
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()[0]["properties"] == []
|
||||
|
||||
|
||||
# --- cluster links ---------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
# Two hosts + a guest → one host↔host cluster link, one host→guest link.
|
||||
nodes = [
|
||||
{**_host_node(), "id": "pve-node-a", "ieee_address": "pve-node-a", "hostname": "a", "label": "a"},
|
||||
{**_host_node(), "id": "pve-node-b", "ieee_address": "pve-node-b", "hostname": "b", "label": "b"},
|
||||
_guest_node(101, "10.0.0.5"),
|
||||
]
|
||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
await _persist_pending_import(db_session, nodes, edges)
|
||||
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
|
||||
assert len(cluster) == 1
|
||||
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
|
||||
# Membership helper sees both endpoints.
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-a") is True
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-b") is True
|
||||
assert await _is_proxmox_cluster_member(db_session, "pve-pve1-101") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_host_records_no_cluster_link(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_host_node()], [])
|
||||
links = (await db_session.execute(
|
||||
select(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "proxmox_cluster")
|
||||
)).scalars().all()
|
||||
assert links == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
|
||||
# Two host nodes already on a canvas + a pending cluster link between them.
|
||||
design = Design(id=str(uuid.uuid4()), name="d")
|
||||
db_session.add(design)
|
||||
dev_a = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-a", status="approved")
|
||||
dev_b = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-b", status="approved")
|
||||
db_session.add_all([dev_a, dev_b])
|
||||
await db_session.flush()
|
||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", device_id=dev_a.id,
|
||||
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
|
||||
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", device_id=dev_b.id,
|
||||
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
||||
discovery_source="proxmox_cluster",
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _resolve_pending_links_for_ieee(db_session, "pve-node-a", design.id)
|
||||
|
||||
edge = (await db_session.execute(select(Edge))).scalars().one()
|
||||
assert edge.type == "cluster"
|
||||
assert edge.source_handle == "right"
|
||||
# Bare side name (canonical) so React Flow resolves it to the left side;
|
||||
# a '-t' target would fall back to the top handle.
|
||||
assert edge.target_handle == "left"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_survives_and_resolves_onto_second_design(db_session) -> None:
|
||||
# Regression: approving the same mesh devices onto a second canvas must also
|
||||
# get their edges. The link row is topology, not one-shot — resolving it on
|
||||
# design A must not consume it, so design B resolves too.
|
||||
da = Design(id=str(uuid.uuid4()), name="a")
|
||||
db_ = Design(id=str(uuid.uuid4()), name="b")
|
||||
db_session.add_all([da, db_])
|
||||
# One row per device, whatever the canvas count.
|
||||
dev_x = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xAAAA", status="approved")
|
||||
dev_y = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xBBBB", status="approved")
|
||||
db_session.add_all([dev_x, dev_y])
|
||||
await db_session.flush()
|
||||
# Same two devices placed on BOTH designs (one Node per canvas).
|
||||
for d in (da, db_):
|
||||
db_session.add_all([
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="x", device_id=dev_x.id,
|
||||
pos_x=0, pos_y=0, design_id=d.id),
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="y", device_id=dev_y.id,
|
||||
pos_x=0, pos_y=0, design_id=d.id),
|
||||
])
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
|
||||
discovery_source="zigbee",
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
first = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", da.id)
|
||||
second = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", db_.id)
|
||||
|
||||
assert len(first) == 1
|
||||
assert len(second) == 1 # would be 0 if the link were consumed on design A
|
||||
# One iot edge per design, each between that design's own nodes.
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 2
|
||||
assert {e.design_id for e in edges} == {da.id, db_.id}
|
||||
assert all(e.type == "iot" for e in edges)
|
||||
# The link row is still present for any further design.
|
||||
assert (await db_session.execute(select(InventoryDeviceLink))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_never_deletes(db_session) -> None:
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
# A later sync that no longer includes vm101 must not remove it.
|
||||
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
ieees = {r.ieee_address for r in rows}
|
||||
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Unit tests for the Proxmox VE import service (parsing, props, sanitizer)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services import proxmox_service as svc
|
||||
|
||||
|
||||
def test_gb_conversion() -> None:
|
||||
assert svc._gb(1024 ** 3) == 1.0
|
||||
assert svc._gb(2 * 1024 ** 3) == 2.0
|
||||
assert svc._gb(0) is None
|
||||
assert svc._gb(None) is None
|
||||
assert svc._gb("nope") is None
|
||||
|
||||
|
||||
def test_extract_qemu_ip_skips_loopback() -> None:
|
||||
payload = {
|
||||
"result": [
|
||||
{"name": "lo", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.1"}]},
|
||||
{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "192.168.1.20"}]},
|
||||
]
|
||||
}
|
||||
assert svc._extract_qemu_ip(payload) == "192.168.1.20"
|
||||
assert svc._extract_qemu_ip(None) is None
|
||||
assert svc._extract_qemu_ip({"result": []}) is None
|
||||
|
||||
|
||||
def test_extract_lxc_ip_parses_net0_static() -> None:
|
||||
cfg = {"net0": "name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.1"}
|
||||
assert svc._extract_lxc_ip(cfg) == "192.168.1.30"
|
||||
# DHCP → no static IP
|
||||
assert svc._extract_lxc_ip({"net0": "name=eth0,bridge=vmbr0,ip=dhcp"}) is None
|
||||
assert svc._extract_lxc_ip(None) is None
|
||||
|
||||
|
||||
def test_extract_net_mac_parses_qemu_and_lxc_forms() -> None:
|
||||
# qemu: "virtio=<MAC>,bridge=.."
|
||||
assert svc._extract_net_mac({"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0"}) == "bc:24:11:aa:bb:cc"
|
||||
# lxc: "..,hwaddr=<MAC>,.."
|
||||
assert (
|
||||
svc._extract_net_mac({"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:11:22:33,ip=dhcp"})
|
||||
== "bc:24:11:11:22:33"
|
||||
)
|
||||
# No MAC / missing net0 / None → None
|
||||
assert svc._extract_net_mac({"net0": "name=eth0,ip=dhcp"}) is None
|
||||
assert svc._extract_net_mac({}) is None
|
||||
assert svc._extract_net_mac(None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_inventory_captures_guest_mac_from_config() -> None:
|
||||
"""Guests carry a normalized NIC MAC read agent-free from their config."""
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/nodes":
|
||||
return [{"node": "pve1", "status": "online", "maxcpu": 8}]
|
||||
if path == "/nodes/pve1/qemu":
|
||||
return [{"vmid": 101, "name": "web", "status": "stopped"}] # stopped: no agent IP
|
||||
if path == "/nodes/pve1/lxc":
|
||||
return [{"vmid": 200, "name": "db", "status": "running"}]
|
||||
if path == "/nodes/pve1/qemu/101/config":
|
||||
return {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0"}
|
||||
if path == "/nodes/pve1/lxc/200/config":
|
||||
return {"net0": "name=eth0,bridge=vmbr0,hwaddr=11:22:33:44:55:66,ip=10.0.0.6/24"}
|
||||
return None
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
nodes, _ = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
||||
|
||||
vm = next(n for n in nodes if n["type"] == "vm")
|
||||
assert vm["mac"] == "aa:bb:cc:dd:ee:ff" # captured even though VM is stopped (no IP)
|
||||
assert vm["ip"] is None
|
||||
ct = next(n for n in nodes if n["type"] == "lxc")
|
||||
assert ct["mac"] == "11:22:33:44:55:66"
|
||||
assert ct["ip"] == "10.0.0.6"
|
||||
|
||||
|
||||
def test_host_and_guest_node_mapping() -> None:
|
||||
host = svc._host_node({"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3, "maxdisk": 500 * 1024 ** 3})
|
||||
assert host["type"] == "proxmox"
|
||||
assert host["ieee_address"] == "pve-node-pve1"
|
||||
assert host["cpu_count"] == 8
|
||||
assert host["ram_gb"] == 16.0
|
||||
assert host["status"] == "online"
|
||||
assert host["parent_ieee"] is None
|
||||
|
||||
vm = svc._guest_node({"vmid": 101, "name": "web", "status": "running", "maxcpu": 2, "maxmem": 2 * 1024 ** 3, "maxdisk": 32 * 1024 ** 3}, "pve1", "qemu", "10.0.0.5")
|
||||
assert vm["type"] == "vm"
|
||||
assert vm["ieee_address"] == "pve-pve1-101"
|
||||
assert vm["ip"] == "10.0.0.5"
|
||||
assert vm["status"] == "online"
|
||||
assert vm["parent_ieee"] == "pve-node-pve1"
|
||||
|
||||
ct = svc._guest_node({"vmid": 200, "name": "db", "status": "stopped"}, "pve1", "lxc", None)
|
||||
assert ct["type"] == "lxc"
|
||||
assert ct["status"] == "offline"
|
||||
|
||||
|
||||
def test_build_properties_includes_specs() -> None:
|
||||
node = {"vmid": 101, "model": "QEMU", "cpu_count": 4, "ram_gb": 8.0, "disk_gb": 40.0}
|
||||
props = svc.build_proxmox_properties(node)
|
||||
keys = {p["key"] for p in props}
|
||||
assert {"VMID", "CPU Cores", "RAM", "Disk", "Source"} <= keys
|
||||
assert all(p["visible"] is False for p in props)
|
||||
|
||||
|
||||
def test_parse_inventory_builds_host_guest_edges() -> None:
|
||||
hosts = [{"node": "pve1", "status": "online", "maxcpu": 4}]
|
||||
guests = {"pve1": [svc._guest_node({"vmid": 101, "name": "web", "status": "running"}, "pve1", "qemu", None)]}
|
||||
nodes, edges = svc._parse_inventory(hosts, guests)
|
||||
assert len(nodes) == 2
|
||||
assert edges == [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
|
||||
|
||||
def test_build_cluster_links_chains_hosts() -> None:
|
||||
nodes = [
|
||||
svc._host_node({"node": "pve-a", "status": "online"}),
|
||||
svc._guest_node({"vmid": 101, "status": "running"}, "pve-a", "qemu", None),
|
||||
svc._host_node({"node": "pve-b", "status": "online"}),
|
||||
svc._host_node({"node": "pve-c", "status": "online"}),
|
||||
]
|
||||
pairs = svc.build_proxmox_cluster_links(nodes)
|
||||
assert pairs == [("pve-node-pve-a", "pve-node-pve-b"), ("pve-node-pve-b", "pve-node-pve-c")]
|
||||
|
||||
|
||||
def test_build_cluster_links_single_host_is_not_a_cluster() -> None:
|
||||
nodes = [svc._host_node({"node": "pve-a", "status": "online"})]
|
||||
assert svc.build_proxmox_cluster_links(nodes) == []
|
||||
# Guests alone never form a cluster.
|
||||
guest = svc._guest_node({"vmid": 1, "status": "running"}, "pve-a", "qemu", None)
|
||||
assert svc.build_proxmox_cluster_links([guest]) == []
|
||||
|
||||
|
||||
def test_sanitize_error_hides_credentials() -> None:
|
||||
exc = httpx.HTTPStatusError(
|
||||
"boom", request=httpx.Request("GET", "https://h/api2/json"),
|
||||
response=httpx.Response(401),
|
||||
)
|
||||
msg = svc._sanitize_proxmox_error(exc)
|
||||
assert "token" not in msg.lower() or "check the api token" in msg.lower()
|
||||
assert "Authentication failed" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_inventory_happy_path() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/nodes":
|
||||
return [{"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3}]
|
||||
if path == "/nodes/pve1/qemu":
|
||||
return [{"vmid": 101, "name": "web", "status": "running", "maxmem": 2 * 1024 ** 3}]
|
||||
if path == "/nodes/pve1/lxc":
|
||||
return [{"vmid": 200, "name": "db", "status": "stopped"}]
|
||||
if path.endswith("/agent/network-get-interfaces"):
|
||||
return {"result": [{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.0.0.5"}]}]}
|
||||
if path.endswith("/config"):
|
||||
return {"net0": "name=eth0,ip=10.0.0.6/24"}
|
||||
return None
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
nodes, edges = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
||||
|
||||
by_type = {n["type"] for n in nodes}
|
||||
assert by_type == {"proxmox", "vm", "lxc"}
|
||||
vm = next(n for n in nodes if n["type"] == "vm")
|
||||
assert vm["ip"] == "10.0.0.5"
|
||||
ct = next(n for n in nodes if n["type"] == "lxc")
|
||||
assert ct["ip"] == "10.0.0.6"
|
||||
assert len(edges) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_returns_message() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/access/permissions":
|
||||
return {"/": {"VM.Audit": 1}} # token has an ACL
|
||||
return {"version": "8.2.2"}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||
assert ok is True
|
||||
assert "8.2.2" in msg
|
||||
assert "warning" not in msg.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_test_connection_warns_when_token_has_no_permissions() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
if path == "/access/permissions":
|
||||
return {} # privilege-separated token with no effective ACL
|
||||
return {"version": "8.4.19"}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||
# Auth still succeeds; the message flags the permission gap.
|
||||
assert ok is True
|
||||
assert "8.4.19" in msg
|
||||
assert "PVEAuditor" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_has_permissions_treats_empty_as_no_perms() -> None:
|
||||
async def fake_get_json(client, path: str):
|
||||
return {}
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||
assert await svc._token_has_permissions(object()) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_has_permissions_assumes_ok_on_error() -> None:
|
||||
async def boom(client, path: str):
|
||||
raise httpx.ConnectError("nope")
|
||||
|
||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=boom)):
|
||||
# Never block a valid import on a permissions-probe failure.
|
||||
assert await svc._token_has_permissions(object()) is True
|
||||
@@ -0,0 +1,34 @@
|
||||
"""The rackable exclusion list is duplicated in the frontend — keep them equal.
|
||||
|
||||
`GET /api/v1/racks/inventory` filters on `_UNRACKABLE_TYPES`, while the Device
|
||||
Inventory modal's "Rackable" filter (used as the rack picker's default) filters
|
||||
on `UNRACKABLE_TYPES` in `frontend/src/utils/rackable.ts`. If the two drift, the
|
||||
picker offers devices the rack side then refuses.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.api.routes.racks import _UNRACKABLE_TYPES
|
||||
|
||||
FRONTEND_FILE = (
|
||||
Path(__file__).resolve().parents[2] / "frontend" / "src" / "utils" / "rackable.ts"
|
||||
)
|
||||
|
||||
|
||||
def _frontend_unrackable() -> set[str]:
|
||||
src = FRONTEND_FILE.read_text()
|
||||
match = re.search(r"export const UNRACKABLE_TYPES = new Set\(\[(.*?)\]\)", src, re.S)
|
||||
assert match, (
|
||||
"Could not locate UNRACKABLE_TYPES in frontend/src/utils/rackable.ts — "
|
||||
"update this parser if the file's shape changed."
|
||||
)
|
||||
return set(re.findall(r"'([^']+)'", match.group(1)))
|
||||
|
||||
|
||||
def test_unrackable_types_in_sync_with_frontend():
|
||||
frontend = _frontend_unrackable()
|
||||
assert frontend == _UNRACKABLE_TYPES, (
|
||||
"racks.py _UNRACKABLE_TYPES is out of sync with frontend rackable.ts.\n"
|
||||
f"Missing from the backend: {sorted(frontend - _UNRACKABLE_TYPES)}\n"
|
||||
f"Missing from the frontend: {sorted(_UNRACKABLE_TYPES - frontend)}"
|
||||
)
|
||||
@@ -0,0 +1,804 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _design(client: AsyncClient, headers: dict, *, name="Rack Room", design_type="rack") -> str:
|
||||
res = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": name, "icon": "server", "design_type": design_type},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()["id"]
|
||||
|
||||
|
||||
def _state(design_id: str, **overrides) -> dict:
|
||||
"""A minimal but complete rack payload: one rack, two devices, one patch."""
|
||||
payload = {
|
||||
"design_id": design_id,
|
||||
"racks": [
|
||||
{
|
||||
"id": "rack-1",
|
||||
"name": "Main",
|
||||
"u_height": 12,
|
||||
"width_standard": "19",
|
||||
"numbering": "bottom-up",
|
||||
"location": "garage",
|
||||
"style": {"frame": "#1c2129", "showNumbers": True},
|
||||
"pos_x": 40,
|
||||
"pos_y": 60,
|
||||
}
|
||||
],
|
||||
"devices": [
|
||||
{
|
||||
"id": "dev-sw",
|
||||
"rack_id": "rack-1",
|
||||
"label": "sw-24",
|
||||
"u_start": 10,
|
||||
"u_height": 1,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "switch-24",
|
||||
"status": "online",
|
||||
"ports": [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}],
|
||||
},
|
||||
{
|
||||
"id": "dev-nas",
|
||||
"rack_id": "rack-1",
|
||||
"label": "nas",
|
||||
"u_start": 4,
|
||||
"u_height": 2,
|
||||
"col_start": 0,
|
||||
"col_span": 12,
|
||||
"faceplate_id": "nas-2u",
|
||||
"status": "unknown",
|
||||
"ports": [{"id": "p2", "label": "eth0", "type": "rj45", "x": 0.7, "y": 0.5}],
|
||||
},
|
||||
],
|
||||
"cables": [
|
||||
{
|
||||
"id": "cbl-1",
|
||||
"from_device_id": "dev-sw",
|
||||
"from_port_id": "p1",
|
||||
"to_device_id": "dev-nas",
|
||||
"to_port_id": "p2",
|
||||
"type": "ethernet",
|
||||
"color": "#39d353",
|
||||
}
|
||||
],
|
||||
"viewport": {"x": 10, "y": 20, "zoom": 1.5},
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
class TestDesignType:
|
||||
async def test_creates_a_rack_design(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
res = await client.get("/api/v1/designs", headers=headers)
|
||||
design = next(d for d in res.json() if d["id"] == design_id)
|
||||
assert design["design_type"] == "rack"
|
||||
|
||||
async def test_rejects_an_unknown_design_type(self, client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Nope", "design_type": "spaceship"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
class TestSaveAndLoad:
|
||||
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
res = await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
assert [r["name"] for r in loaded["racks"]] == ["Main"]
|
||||
assert loaded["racks"][0]["u_height"] == 12
|
||||
assert loaded["racks"][0]["style"]["showNumbers"] is True
|
||||
assert {d["id"] for d in loaded["devices"]} == {"dev-sw", "dev-nas"}
|
||||
assert loaded["devices"][0]["ports"][0]["type"] == "rj45"
|
||||
assert loaded["cables"][0]["from_port_id"] == "p1"
|
||||
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
|
||||
|
||||
async def test_keeps_a_mount_that_follows_its_node_check(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# "Check device" is stored as `auto` on the mount: the rack has no
|
||||
# checker, it reads the linked node's live status at render time. The
|
||||
# status column is free-form, so this only guards it staying that way.
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id)
|
||||
state["devices"][0]["status"] = "auto"
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
mount = next(d for d in loaded["devices"] if d["id"] == "dev-sw")
|
||||
assert mount["status"] == "auto"
|
||||
|
||||
async def test_prunes_what_the_client_dropped(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
trimmed = _state(design_id)
|
||||
trimmed["devices"] = [d for d in trimmed["devices"] if d["id"] == "dev-sw"]
|
||||
trimmed["cables"] = []
|
||||
await client.post("/api/v1/racks/save", json=trimmed, headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
assert {d["id"] for d in loaded["devices"]} == {"dev-sw"}
|
||||
assert loaded["cables"] == []
|
||||
|
||||
async def test_updates_an_existing_device_in_place(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
moved = _state(design_id)
|
||||
moved["devices"][1]["u_start"] = 1
|
||||
moved["devices"][1]["label"] = "nas-renamed"
|
||||
await client.post("/api/v1/racks/save", json=moved, headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
nas = next(d for d in loaded["devices"] if d["id"] == "dev-nas")
|
||||
assert (nas["u_start"], nas["label"]) == (1, "nas-renamed")
|
||||
|
||||
async def test_keeps_designs_isolated(self, client: AsyncClient, headers):
|
||||
first = await _design(client, headers, name="Rack A")
|
||||
second = await _design(client, headers, name="Rack B")
|
||||
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={second}", headers=headers)).json()
|
||||
assert loaded["racks"] == []
|
||||
assert loaded["devices"] == []
|
||||
|
||||
async def test_refuses_to_steal_a_row_from_another_design(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
"""Ids come from the client, so a stale tab or a copy can collide.
|
||||
|
||||
The upsert sets `design_id` from the payload: without a guard, saving
|
||||
design B with an id owned by design A moved A's rack — devices and
|
||||
cables with it — into B, and A silently lost it.
|
||||
"""
|
||||
first = await _design(client, headers, name="Rack A")
|
||||
second = await _design(client, headers, name="Rack B")
|
||||
await client.post("/api/v1/racks/save", json=_state(first), headers=headers)
|
||||
|
||||
res = await client.post("/api/v1/racks/save", json=_state(second), headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
kept = (await client.get(f"/api/v1/racks?design_id={first}", headers=headers)).json()
|
||||
assert [r["id"] for r in kept["racks"]] == ["rack-1"]
|
||||
assert len(kept["devices"]) == 2
|
||||
|
||||
async def test_rejects_an_unknown_design(self, client: AsyncClient, headers):
|
||||
res = await client.post("/api/v1/racks/save", json=_state("nope"), headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
async def test_rejects_a_device_pointing_outside_the_payload(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
broken = _state(design_id)
|
||||
broken["devices"][0]["rack_id"] = "rack-ghost"
|
||||
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
async def test_rejects_a_cable_pointing_outside_the_payload(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
broken = _state(design_id)
|
||||
broken["cables"][0]["to_device_id"] = "dev-ghost"
|
||||
res = await client.post("/api/v1/racks/save", json=broken, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"u_height": 0},
|
||||
{"width_standard": "23"},
|
||||
{"numbering": "sideways"},
|
||||
],
|
||||
)
|
||||
async def test_rejects_invalid_rack_geometry(self, client: AsyncClient, headers, patch):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["racks"][0].update(patch)
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"u_start": 0},
|
||||
{"col_start": 12},
|
||||
{"col_span": 13},
|
||||
],
|
||||
)
|
||||
async def test_rejects_invalid_device_geometry(self, client: AsyncClient, headers, patch):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update(patch)
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_rejects_a_device_taller_than_its_rack(self, client: AsyncClient, headers):
|
||||
# Each field is legal on its own; only the rack says otherwise. A mount
|
||||
# above the top rail draws outside the chassis with no way to drag it back.
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["u_start"] = 40 # rack is 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_rejects_a_device_whose_height_overruns_the_rack(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"u_start": 11, "u_height": 4}) # 11..14 of 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_accepts_a_device_that_ends_on_the_top_rail(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"u_start": 11, "u_height": 2}) # 11..12 of 12U
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
async def test_rejects_a_span_that_overruns_the_column_grid(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"col_start": 11, "col_span": 12})
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_accepts_a_half_width_pair_sharing_one_u(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0].update({"col_start": 0, "col_span": 6})
|
||||
payload["devices"][1].update({"col_start": 6, "col_span": 6})
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
async def test_rejects_an_unknown_cable_type(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["type"] = "power"
|
||||
res = await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_round_trips_cable_annotations(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0].update(
|
||||
{
|
||||
"label": "Uplink to core",
|
||||
"label_visible": True,
|
||||
"properties": [
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
|
||||
{"key": "VLAN", "value": "20", "icon": None, "visible": False},
|
||||
],
|
||||
}
|
||||
)
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["label"] == "Uplink to core"
|
||||
assert cable["label_visible"] is True
|
||||
assert [p["key"] for p in cable["properties"]] == ["Length", "VLAN"]
|
||||
assert cable["properties"][1]["visible"] is False
|
||||
|
||||
async def test_defaults_cable_annotations_when_absent(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# A client that predates the feature sends neither key; the response must
|
||||
# still carry usable values rather than nulls.
|
||||
design_id = await _design(client, headers)
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["label_visible"] is False
|
||||
assert cable["properties"] == []
|
||||
|
||||
async def test_drops_cable_properties_with_no_key(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["properties"] = [
|
||||
{"key": " ", "value": "orphan", "icon": None, "visible": True},
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True},
|
||||
]
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
).status_code == 200
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert [p["key"] for p in cable["properties"]] == ["Length"]
|
||||
|
||||
async def test_updates_cable_annotations_on_a_second_save(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
payload = _state(design_id)
|
||||
payload["cables"][0]["properties"] = [
|
||||
{"key": "Length", "value": "2 m", "icon": None, "visible": True}
|
||||
]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
payload["cables"][0]["properties"] = []
|
||||
payload["cables"][0]["label_visible"] = True
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
cable = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["cables"][0]
|
||||
assert cable["properties"] == []
|
||||
assert cable["label_visible"] is True
|
||||
|
||||
async def test_requires_auth(self, client: AsyncClient):
|
||||
assert (await client.get("/api/v1/racks?design_id=any")).status_code == 401
|
||||
|
||||
|
||||
class TestInventory:
|
||||
async def test_lists_rackable_devices_only(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pve-01", "suggested_type": "proxmox"},
|
||||
headers=headers,
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "phone", "suggested_type": "mobile"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
labels = [i["label"] for i in res.json()["items"]]
|
||||
assert "pve-01" in labels
|
||||
assert "phone" not in labels
|
||||
|
||||
async def test_names_a_device_the_way_the_inventory_does(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""A scan find with no hostname is named after the app it runs, not its IP.
|
||||
|
||||
The rack picker used to show "192.168.1.63 · 192.168.1.63" where the
|
||||
Device Inventory showed "jellyfin".
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-services",
|
||||
ip="192.168.1.63",
|
||||
services=[
|
||||
{"port": 443, "category": "web", "service_name": "https"},
|
||||
{"port": 8096, "category": "media", "service_name": "jellyfin"},
|
||||
],
|
||||
suggested_type="server",
|
||||
)
|
||||
)
|
||||
db_session.add(InventoryDevice(id="pd-bare", ip="192.168.1.64", suggested_type="server"))
|
||||
# A portless service is no fingerprint: the inventory skips it, so must
|
||||
# this — `None not in _COMMON_PORTS` is True and used to let it through.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-portless",
|
||||
ip="192.168.1.65",
|
||||
services=[{"port": None, "category": "media", "service_name": "jellyfin"}],
|
||||
suggested_type="server",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert next(i for i in items if i["id"] == "pd-services")["label"] == "jellyfin"
|
||||
# Nothing better to say: the IP stays the label, and the UI stops doubling it.
|
||||
assert next(i for i in items if i["id"] == "pd-bare")["label"] == "192.168.1.64"
|
||||
assert next(i for i in items if i["id"] == "pd-portless")["label"] == "192.168.1.65"
|
||||
|
||||
async def test_flags_devices_already_mounted(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "sw-24", "suggested_type": "switch"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = created["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert next(i for i in items if i["id"] == created["id"])["racked"] is True
|
||||
|
||||
async def test_resolves_the_canvas_node_for_live_status(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
node = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "nas", "ip": "192.168.1.9", "status": "online"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nas", "ip": "192.168.1.9", "suggested_type": "nas"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["label"] == "nas")
|
||||
assert entry["node_id"] == node["id"]
|
||||
assert entry["node_status"] == "online"
|
||||
|
||||
async def test_reports_what_the_canvas_node_knows(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The rack prints the logical view's facts, so the endpoint must ship them."""
|
||||
rack_design = await _design(client, headers)
|
||||
network = await _design(client, headers, name="Network", design_type="network")
|
||||
# Straight to the table: the manual-create schema carries no os/services,
|
||||
# and a scan find does. Seeded before the node so the node links to this
|
||||
# row rather than minting a second one for the same host.
|
||||
db_session.add(
|
||||
InventoryDevice(
|
||||
id="pd-nas",
|
||||
hostname="nas",
|
||||
ip="192.168.1.9",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
os="TrueNAS",
|
||||
suggested_type="nas",
|
||||
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas",
|
||||
"label": "nas-truenas",
|
||||
"design_id": network,
|
||||
"ip": "192.168.1.9",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"hostname": "nas.lan",
|
||||
"os": "TrueNAS SCALE",
|
||||
"status": "online",
|
||||
"check_method": "http",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["ip"] == "192.168.1.9")
|
||||
assert entry["mac"] == "aa:bb:cc:dd:ee:ff"
|
||||
assert entry["os"] == "TrueNAS"
|
||||
assert entry["services"] == [{"port": 445, "name": "smb"}]
|
||||
assert entry["node_label"] == "nas-truenas"
|
||||
assert entry["node_type"] == "nas"
|
||||
# The node no longer keeps its own copy: both views read the row, which
|
||||
# the node create filled in without overwriting what the scan found.
|
||||
assert entry["node_hostname"] == "nas"
|
||||
assert entry["node_os"] == "TrueNAS"
|
||||
assert entry["node_check_method"] == "http"
|
||||
assert entry["node_design_id"] == network
|
||||
assert entry["node_design_name"] == "Network"
|
||||
|
||||
async def test_stamps_last_seen_with_an_offset(self, client: AsyncClient, headers, db_session):
|
||||
"""SQLite reads `last_seen` back naive, and a naive ISO string is *local*
|
||||
time to `new Date()` — the panel printed it shifted by the viewer's offset.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
node = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "seen", "ip": "192.168.1.77"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
await db_session.execute(
|
||||
text("UPDATE device_inventory SET last_seen = :ts WHERE id = :id"),
|
||||
{"ts": "2026-08-08 10:00:00.000000", "id": node["device_id"]},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == node["device_id"])
|
||||
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
|
||||
|
||||
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
|
||||
"""A link the user picked in the rack beats the IEEE/IP guess.
|
||||
|
||||
The guess is what failed on gear with no MAC and no address, and it is
|
||||
wrong — not merely absent — when two hosts have swapped IPs.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
picked = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "pdu", "label": "pdu-main", "status": "online"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
guessed = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "someone-else", "ip": "192.168.1.90"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
device = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pdu", "ip": "192.168.1.90", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = device["id"]
|
||||
payload["devices"][0]["node_id"] = picked["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == device["id"])
|
||||
assert entry["node_id"] == picked["id"] != guessed["id"]
|
||||
assert entry["node_label"] == "pdu-main"
|
||||
assert entry["node_status"] == "online"
|
||||
|
||||
async def test_falls_back_when_the_named_node_is_gone(self, client: AsyncClient, headers):
|
||||
"""A mount keeps its `node_id` in the payload the client last sent, so a
|
||||
node deleted meanwhile must not report a node that no longer exists.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
guessed = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "server", "label": "nas", "ip": "192.168.1.91"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
device = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nas", "ip": "192.168.1.91", "suggested_type": "nas"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = device["id"]
|
||||
payload["devices"][0]["node_id"] = "node-that-was-deleted"
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == device["id"])
|
||||
assert entry["node_id"] == guessed["id"]
|
||||
|
||||
async def test_leaves_the_node_fields_empty_without_a_match(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "pdu-main", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["label"] == "pdu-main")
|
||||
assert entry["node_id"] is None
|
||||
assert entry["node_label"] is None
|
||||
assert entry["node_design_name"] is None
|
||||
assert entry["services"] == []
|
||||
|
||||
async def test_reports_the_services_a_canvas_added(self, client: AsyncClient, headers, db_session):
|
||||
"""Services documented on a canvas reach the rack — they are the device's.
|
||||
|
||||
The scan found the host but no services; the user typed one on the
|
||||
logical canvas, and the rack prints it without the node being consulted.
|
||||
"""
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(
|
||||
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
|
||||
)
|
||||
await db_session.commit()
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "server",
|
||||
"label": "media",
|
||||
"ip": "192.168.1.63",
|
||||
"services": [{"port": 8096, "service_name": "jellyfin", "category": "media"}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == "pd-noservices")
|
||||
assert entry["services"] == [{"port": 8096, "name": "jellyfin"}]
|
||||
|
||||
async def test_manual_entry_is_tagged_as_such(self, client: AsyncClient, headers):
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "patch-panel", "suggested_type": "generic"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
assert created["discovery_source"] == "manual"
|
||||
assert created["status"] == "pending"
|
||||
|
||||
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert any(d["id"] == created["id"] for d in listed)
|
||||
|
||||
|
||||
class TestRackSourcedInventory:
|
||||
"""Gear created from a rack canvas shares the Device Inventory, but never a
|
||||
logical canvas: it documents a mount, not a host."""
|
||||
|
||||
async def _rack_device(self, client: AsyncClient, headers, hostname: str = "patch-house"):
|
||||
return (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": hostname, "discovery_source": "rack"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
async def test_records_the_rack_source(self, client: AsyncClient, headers):
|
||||
created = await self._rack_device(client, headers)
|
||||
assert created["discovery_source"] == "rack"
|
||||
assert created["discovery_sources"] == ["rack"]
|
||||
|
||||
async def test_rejects_an_unknown_source(self, client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "spoof", "discovery_source": "zigbee"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
async def test_approve_refuses_rack_gear(self, client: AsyncClient, headers):
|
||||
created = await self._rack_device(client, headers)
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{created['id']}/approve",
|
||||
json={"type": "generic", "label": "patch-house"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 409
|
||||
assert "rack" in res.json()["detail"].lower()
|
||||
|
||||
async def test_bulk_approve_skips_rack_gear(self, client: AsyncClient, headers):
|
||||
rack_device = await self._rack_device(client, headers)
|
||||
scanned = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "nuc", "ip": "192.168.1.77", "suggested_type": "server"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [rack_device["id"], scanned["id"]]},
|
||||
headers=headers,
|
||||
)
|
||||
body = res.json()
|
||||
assert body["approved"] == 1
|
||||
assert body["device_ids"] == [scanned["id"]]
|
||||
skipped = next(s for s in body["skipped_devices"] if s["device_id"] == rack_device["id"])
|
||||
assert skipped["match"] == "rack"
|
||||
|
||||
async def test_rack_gear_is_offered_to_the_rack_inventory(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = await self._rack_device(client, headers, hostname="blank-panel")
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
assert any(i["id"] == created["id"] for i in items)
|
||||
|
||||
|
||||
class TestDesignLifecycle:
|
||||
async def test_deleting_a_design_removes_its_rack_rows(self, client: AsyncClient, headers):
|
||||
keeper = await _design(client, headers, name="Keeper", design_type="network")
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
assert (await client.delete(f"/api/v1/designs/{design_id}", headers=headers)).status_code == 204
|
||||
assert (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).status_code == 404
|
||||
# The other design is untouched.
|
||||
assert (await client.get(f"/api/v1/racks?design_id={keeper}", headers=headers)).status_code == 200
|
||||
|
||||
async def test_unmounting_keeps_the_inventory_entry(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
created = (
|
||||
await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={"hostname": "sw-24", "suggested_type": "switch"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
|
||||
payload = _state(design_id)
|
||||
payload["devices"][0]["device_id"] = created["id"]
|
||||
await client.post("/api/v1/racks/save", json=payload, headers=headers)
|
||||
|
||||
# Unmount: the device disappears from the rack payload entirely.
|
||||
unmounted = _state(design_id)
|
||||
unmounted["devices"] = [d for d in unmounted["devices"] if d["id"] != "dev-sw"]
|
||||
unmounted["cables"] = []
|
||||
await client.post("/api/v1/racks/save", json=unmounted, headers=headers)
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
entry = next(i for i in items if i["id"] == created["id"])
|
||||
assert entry["racked"] is False
|
||||
|
||||
async def test_copying_a_rack_design_duplicates_its_racks(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
await client.post("/api/v1/racks/save", json=_state(design_id), headers=headers)
|
||||
|
||||
copy = (
|
||||
await client.post(
|
||||
f"/api/v1/designs/{design_id}/copy",
|
||||
json={"name": "Rack Room (copy)", "icon": "server"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
assert copy["design_type"] == "rack"
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={copy['id']}", headers=headers)).json()
|
||||
assert [r["name"] for r in loaded["racks"]] == ["Main"]
|
||||
assert len(loaded["devices"]) == 2
|
||||
assert len(loaded["cables"]) == 1
|
||||
# Fresh ids, so editing the copy cannot touch the original.
|
||||
assert loaded["racks"][0]["id"] != "rack-1"
|
||||
assert {d["id"] for d in loaded["devices"]}.isdisjoint({"dev-sw", "dev-nas"})
|
||||
# Cables still join the copied devices, not the originals.
|
||||
copied_ids = {d["id"] for d in loaded["devices"]}
|
||||
assert loaded["cables"][0]["from_device_id"] in copied_ids
|
||||
assert loaded["cables"][0]["to_device_id"] in copied_ids
|
||||
@@ -1,986 +0,0 @@
|
||||
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop."""
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
||||
|
||||
|
||||
@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}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pending_device(db_session):
|
||||
import uuid
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.100",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
hostname="my-server",
|
||||
os="Linux",
|
||||
services=[{"port": 22, "name": "ssh"}],
|
||||
suggested_type="server",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
# --- _background_scan error handling ---
|
||||
|
||||
@pytest.fixture
|
||||
async def mem_db():
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.database import Base
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
||||
session rollback path must execute without a follow-on exception."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
||||
must NOT overwrite that terminal status with 'failed'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.scan.run_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
run_id = run.id
|
||||
|
||||
with (
|
||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
||||
):
|
||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
||||
mock_run_scan.assert_awaited_once()
|
||||
|
||||
|
||||
# --- Trigger scan ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/scan/trigger")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
||||
with (
|
||||
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
||||
patch("app.api.routes.scan.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["ranges"] == ["192.168.1.0/24"]
|
||||
assert "id" in data
|
||||
|
||||
|
||||
# --- Pending devices ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["ip"] == "192.168.1.100"
|
||||
assert data[0]["hostname"] == "my-server"
|
||||
|
||||
|
||||
# --- Approve device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
||||
node_payload = {
|
||||
"label": "My Server",
|
||||
"type": "server",
|
||||
"ip": "192.168.1.100",
|
||||
"hostname": "my-server",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert "node_id" in data
|
||||
|
||||
# Device should no longer appear in pending list
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||
node_payload = {
|
||||
"label": "Ghost",
|
||||
"type": "generic",
|
||||
"ip": "10.0.0.1",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/nonexistent-id/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# --- Hide device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["hidden"] is True
|
||||
|
||||
# Should no longer appear in pending
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
# Should appear in hidden
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 1
|
||||
|
||||
|
||||
# --- Restore hidden device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
||||
# Hide first
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
# Restore
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] is True
|
||||
|
||||
# Now back in pending, gone from hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
||||
# Hide
|
||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-restore",
|
||||
headers=headers,
|
||||
json={"device_ids": [pending_device.id]},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["restored"] == 1
|
||||
assert res.json()["skipped"] == 0
|
||||
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert len(pending_res.json()) == 1
|
||||
|
||||
|
||||
# --- Ignore device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ignored"] is True
|
||||
|
||||
# Device should be gone from both pending and hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
# --- Scan runs ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/runs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
# --- run_scan: re-scan updates existing pending devices ---
|
||||
|
||||
MOCK_HOST = {
|
||||
"ip": "192.168.1.50",
|
||||
"mac": "aa:bb:cc:dd:ee:ff",
|
||||
"hostname": "myhost.lan",
|
||||
"os": "Linux",
|
||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
assert device.suggested_type == "server"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_purges_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
||||
"""Pending devices that were already in canvas before scan starts must be removed."""
|
||||
node = Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
ip="192.168.1.50",
|
||||
status="online",
|
||||
services=[],
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
stale = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(node)
|
||||
db_session.add(stale)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_ip_already_in_canvas(db_session: AsyncSession):
|
||||
"""Devices whose IP already exists as a canvas Node must not appear in pending."""
|
||||
node = Node(
|
||||
id=str(uuid.uuid4()),
|
||||
label="Existing Server",
|
||||
type="server",
|
||||
ip="192.168.1.50",
|
||||
status="online",
|
||||
services=[],
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
||||
hidden = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="hidden",
|
||||
)
|
||||
db_session.add(hidden)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.ip == "192.168.1.50",
|
||||
PendingDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
|
||||
# --- Stop scan ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/scan/fake-id/stop")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
||||
import uuid as _uuid
|
||||
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
||||
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
||||
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == {"stopping": True}
|
||||
# run_id added to cancel set
|
||||
assert run.id in _cancelled_runs
|
||||
# cleanup for other tests
|
||||
_cancelled_runs.discard(run.id)
|
||||
|
||||
|
||||
# --- run_scan cancellation ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
||||
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
request_cancel(run_id)
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
# nmap should not have been called — cancelled before first range
|
||||
mock_nmap.assert_not_called()
|
||||
|
||||
await db_session.refresh(run)
|
||||
assert run.status == "cancelled"
|
||||
assert run.finished_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
||||
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def nmap_side_effect(target: str):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Signal cancellation after the first CIDR scan completes
|
||||
if call_count == 1:
|
||||
request_cancel(run_id)
|
||||
return []
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
||||
|
||||
assert call_count == 1 # second CIDR was skipped
|
||||
await db_session.refresh(run)
|
||||
assert run.status == "cancelled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||
# Pre-existing pending device with no services
|
||||
existing = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
hostname=None,
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
with (
|
||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||
):
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
# Should still be only one device
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
assert len(devices) == 1
|
||||
device = devices[0]
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
# --- Bulk approve ---
|
||||
|
||||
@pytest.fixture
|
||||
async def two_pending_devices(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
hostname=f"host-{i}",
|
||||
os=None,
|
||||
services=[],
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(d)
|
||||
devices.append(d)
|
||||
await db_session.commit()
|
||||
for d in devices:
|
||||
await db_session.refresh(d)
|
||||
return devices
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 2
|
||||
assert len(data["node_ids"]) == 2
|
||||
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
||||
assert len(data["device_ids"]) == 2
|
||||
assert data["skipped"] == 0
|
||||
# Pending list should now be empty
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=None,
|
||||
mac=None,
|
||||
hostname=None,
|
||||
friendly_name="bulb_1",
|
||||
services=[],
|
||||
suggested_type="zigbee_enddevice",
|
||||
device_subtype="EndDevice",
|
||||
ieee_address="0xABCDEF",
|
||||
vendor="IKEA",
|
||||
model="TRADFRI",
|
||||
lqi=180,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_device_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
payload = {
|
||||
"label": "bulb_1",
|
||||
"type": "zigbee_enddevice",
|
||||
"status": "online",
|
||||
"services": [],
|
||||
"check_method": "none",
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
assert keys == {
|
||||
"IEEE": "0xABCDEF",
|
||||
"Vendor": "IKEA",
|
||||
"Model": "TRADFRI",
|
||||
"LQI": "180",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_zigbee_populates_properties(
|
||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/bulk-approve",
|
||||
json={"device_ids": [zigbee_pending_device.id]},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
assert keys["IEEE"] == "0xABCDEF"
|
||||
assert keys["Vendor"] == "IKEA"
|
||||
assert keys["Model"] == "TRADFRI"
|
||||
assert keys["LQI"] == "180"
|
||||
assert node.check_method == "none"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
||||
for n in nodes:
|
||||
if n.ip:
|
||||
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||
assert node is not None
|
||||
assert node.check_method == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Approve first device individually first
|
||||
await client.post(
|
||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
||||
headers=headers,
|
||||
)
|
||||
# Bulk approve both — first one is already approved (not pending), should be skipped
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# --- Bulk hide ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 2
|
||||
assert data["skipped"] == 0
|
||||
# Should appear in hidden list
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
# Hide first device individually first
|
||||
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
||||
# Bulk hide both — first is already hidden (not pending anymore)
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["hidden"] == 1
|
||||
assert data["skipped"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Approve auto-creates Edges from pending_device_links (Zigbee flow)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _seed_zigbee_pending_pair(db_session):
|
||||
"""Create a coordinator Node + a pending device + a link between them."""
|
||||
from app.db.models import Node, PendingDevice, PendingDeviceLink
|
||||
|
||||
coord = Node(
|
||||
label="Coordinator",
|
||||
type="zigbee_coordinator",
|
||||
status="unknown",
|
||||
ieee_address="0xCOORD",
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
pending = PendingDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
device_subtype="Router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(pending)
|
||||
|
||||
db_session.add(
|
||||
PendingDeviceLink(
|
||||
source_ieee="0xCOORD",
|
||||
target_ieee="0xR1",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
return coord, pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending.id}/approve",
|
||||
json={
|
||||
"label": "router_1",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert data["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
assert edges[0].source == coord.id
|
||||
assert edges[0].target == data["node_id"]
|
||||
assert edges[0].source_handle == "bottom"
|
||||
assert edges[0].target_handle == "top-t"
|
||||
assert edges[0].type == "iot"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_duplicate_edge(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Re-running the resolution does not create a second edge for the same pair."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
||||
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
|
||||
|
||||
# Simulate a second pending row + link between same coord and a new device,
|
||||
# but keep an existing edge in place to verify dedupe also handles
|
||||
# the swapped-direction case.
|
||||
new_pending = PendingDevice(
|
||||
ieee_address="0xR1B",
|
||||
friendly_name="r1b",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(new_pending)
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
|
||||
)
|
||||
assert res.json()["edges_created"] == 1 # only the new pair
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 2 # original + new, no duplicate
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{a.id}/approve",
|
||||
json={
|
||||
"label": "a",
|
||||
"type": "zigbee_router",
|
||||
"ip": None,
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["edges_created"] == 0
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert edges == []
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # preserved for later resolution
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""First approval keeps link; second approval creates the edge."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
||||
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
|
||||
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
|
||||
assert res.json()["edges_created"] == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
assert links == [] # consumed
|
||||
+472
-17
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -253,6 +253,128 @@ def test_nmap_scan_single_returns_host_unchanged_when_no_results():
|
||||
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan_single — two-pass discovery/version split (issue #277)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fake_scanner(ip, port_info, mac=None):
|
||||
"""Mock nmap.PortScanner whose results contain `ip` with tcp `port_info`."""
|
||||
host = MagicMock()
|
||||
host.all_protocols.return_value = ["tcp"]
|
||||
host.__getitem__ = MagicMock(return_value=port_info)
|
||||
host.get.return_value = {"mac": mac} if mac else {}
|
||||
nm = MagicMock()
|
||||
nm.all_hosts.return_value = [ip]
|
||||
nm.__getitem__ = MagicMock(return_value=host)
|
||||
return nm
|
||||
|
||||
|
||||
def _empty_scanner():
|
||||
nm = MagicMock()
|
||||
nm.all_hosts.return_value = []
|
||||
return nm
|
||||
|
||||
|
||||
def _failing_scanner(exc=Exception("host timeout")):
|
||||
nm = MagicMock()
|
||||
nm.scan.side_effect = exc
|
||||
return nm
|
||||
|
||||
|
||||
def test_nmap_scan_single_two_pass_merges_banners():
|
||||
"""Pass A discovers ports; Pass B enriches them with -sV banners."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.10", {22: {"state": "open"}, 8006: {"state": "open"}})
|
||||
ver = _fake_scanner("192.168.1.10", {
|
||||
22: {"state": "open", "product": "OpenSSH", "version": "9.0"},
|
||||
8006: {"state": "open", "product": "", "version": ""},
|
||||
})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=0), \
|
||||
patch("app.services.scanner._extract_os", return_value=None):
|
||||
result = _nmap_scan_single(host)
|
||||
|
||||
banners = {p["port"]: p["banner"] for p in result["open_ports"]}
|
||||
assert banners == {22: "OpenSSH 9.0", 8006: ""}
|
||||
|
||||
# Pass A: discovery only, no -sV / host-timeout. Pass B: -sV, bounded.
|
||||
disc_args = disc.scan.call_args.kwargs["arguments"]
|
||||
ver_args = ver.scan.call_args.kwargs["arguments"]
|
||||
assert "-sV" not in disc_args and "--host-timeout" not in disc_args
|
||||
assert "-sV" in ver_args and "--host-timeout 60s" in ver_args
|
||||
assert ver_args.endswith("-p 22,8006") # version pass scoped to found ports
|
||||
|
||||
|
||||
def test_nmap_scan_single_keeps_ports_when_version_pass_fails():
|
||||
"""Regression #277: a stalling version pass must not drop discovered ports."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.100.3", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.100.3", {22: {"state": "open"}, 8006: {"state": "open"}})
|
||||
ver = _failing_scanner() # -sV blows past --host-timeout on the TLS port
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
||||
result = _nmap_scan_single(host)
|
||||
|
||||
ports = {p["port"] for p in result["open_ports"]}
|
||||
assert ports == {22, 8006} # both survive despite the version failure
|
||||
assert all(p["banner"] == "" for p in result["open_ports"])
|
||||
|
||||
|
||||
def test_nmap_scan_single_keeps_ports_when_version_pass_empty():
|
||||
"""Version pass returns no results for the host — keep the discovered ports."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.11", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.11", {443: {"state": "open"}})
|
||||
ver = _empty_scanner()
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
||||
result = _nmap_scan_single(host)
|
||||
|
||||
assert [p["port"] for p in result["open_ports"]] == [443]
|
||||
assert result["open_ports"][0]["banner"] == ""
|
||||
|
||||
|
||||
def test_nmap_scan_single_no_open_ports_skips_version_pass():
|
||||
"""Host reachable but nothing open — no version pass, empty ports."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.12", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.12", {80: {"state": "filtered"}})
|
||||
|
||||
# Only one PortScanner instance may be created (Pass B must be skipped);
|
||||
# a second would raise StopIteration from side_effect.
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
||||
result = _nmap_scan_single(host)
|
||||
|
||||
assert result["open_ports"] == []
|
||||
|
||||
|
||||
def test_nmap_scan_single_non_root_uses_connect_scan():
|
||||
"""Without root, both passes use -sT (connect) instead of -sS (SYN)."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.13", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.13", {80: {"state": "open"}})
|
||||
ver = _fake_scanner("192.168.1.13", {80: {"state": "open", "product": "nginx", "version": "1.24"}})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000), \
|
||||
patch("app.services.scanner._extract_os", return_value=None):
|
||||
result = _nmap_scan_single(host)
|
||||
|
||||
assert disc.scan.call_args.kwargs["arguments"].startswith("-sT")
|
||||
assert ver.scan.call_args.kwargs["arguments"].startswith("-sT")
|
||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -277,6 +399,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 +549,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":
|
||||
@@ -398,15 +588,106 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice))
|
||||
result = await session.execute(sa_select(InventoryDevice))
|
||||
devices = result.scalars().all()
|
||||
|
||||
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 known device stamps last_scan on its inventory row.
|
||||
|
||||
The stamp is a fact about the device, so it lands once on the row every
|
||||
canvas draws — not once per node.
|
||||
"""
|
||||
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))
|
||||
device = InventoryDevice(id="d1", ip="192.168.1.5", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n1", type="server", label="NAS", device_id="d1"))
|
||||
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:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
assert device.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||
"""A device 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))
|
||||
device = InventoryDevice(id="d2", mac="aa:bb:cc:dd:ee:ff", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n2", type="iot", label="Sensor", device_id="d2"))
|
||||
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:
|
||||
device = await session.get(InventoryDevice, "d2")
|
||||
|
||||
assert device is not None
|
||||
assert device.last_scan is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
"""A device whose IP/MAC the scan never saw 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))
|
||||
device = InventoryDevice(id="d3", ip="10.0.0.99", status="approved")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
session.add(Node(id="n3", type="server", label="Other", device_id="d3"))
|
||||
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:
|
||||
device = await session.get(InventoryDevice, "d3")
|
||||
|
||||
assert device is not None
|
||||
assert device.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."""
|
||||
"""Devices found only by mDNS (not nmap) should appear in device_inventory."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
@@ -423,7 +704,7 @@ async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.80"))
|
||||
device = result.scalar_one_or_none()
|
||||
|
||||
assert device is not None
|
||||
@@ -431,6 +712,44 @@ async def test_run_scan_mdns_only_device_added(mem_db):
|
||||
assert device.discovery_source == "mdns"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
"""A scan reconciles a prior Proxmox-imported row by MAC: fills the IP,
|
||||
unions the source, keeps the vm type, and does not duplicate."""
|
||||
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))
|
||||
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
|
||||
session.add(InventoryDevice(
|
||||
id="pve-row", ieee_address="pve-pve1-101", ip=None,
|
||||
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
|
||||
discovery_source="proxmox", discovery_sources=["proxmox"],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
# Scan sees the same box (same MAC, different casing) with a live IP.
|
||||
nmap_hosts = [{"ip": "192.168.1.50", "hostname": "web.lan",
|
||||
"mac": "BC:24:11:AA:BB:CC", "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:
|
||||
rows = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert len(rows) == 1 # merged, not duplicated
|
||||
row = rows[0]
|
||||
assert row.ip == "192.168.1.50" # scan filled the IP
|
||||
assert row.mac == "bc:24:11:aa:bb:cc" # normalized
|
||||
assert row.suggested_type == "vm" # kept proxmox type
|
||||
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||
@@ -450,25 +769,27 @@ async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.10"))
|
||||
devices = result.scalars().all()
|
||||
|
||||
assert len(devices) == 1 # not duplicated
|
||||
|
||||
|
||||
@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()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
canvas_node = Node(
|
||||
id=str(uuid.uuid4()), label="PVE", type="proxmox",
|
||||
ip="192.168.1.100", status="online",
|
||||
)
|
||||
session.add(canvas_node)
|
||||
drawn = InventoryDevice(id="d-pve", ip="192.168.1.100", status="pending")
|
||||
session.add(drawn)
|
||||
await session.flush()
|
||||
session.add(Node(
|
||||
id=str(uuid.uuid4()), label="PVE", type="proxmox", device_id="d-pve",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
|
||||
@@ -480,8 +801,10 @@ async def test_run_scan_skips_canvas_nodes(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
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
|
||||
result = await session.execute(sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.100"))
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
assert device.status == "pending"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -492,7 +815,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
|
||||
hidden = InventoryDevice(ip="192.168.1.55", status="hidden")
|
||||
session.add(hidden)
|
||||
await session.commit()
|
||||
|
||||
@@ -506,7 +829,7 @@ async def test_run_scan_skips_hidden_devices(mem_db):
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(
|
||||
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
|
||||
sa_select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.55", InventoryDevice.status == "pending")
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
|
||||
@@ -533,3 +856,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(InventoryDevice).where(InventoryDevice.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()
|
||||
|
||||
+479
-26
@@ -5,20 +5,44 @@ 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_proxmox_sync,
|
||||
_run_service_checks,
|
||||
_run_status_checks,
|
||||
_run_zigbee_sync,
|
||||
_run_zwave_sync,
|
||||
reschedule_proxmox_sync,
|
||||
reschedule_service_checks,
|
||||
reschedule_status_checks,
|
||||
reschedule_zigbee_sync,
|
||||
reschedule_zwave_sync,
|
||||
set_proxmox_sync_enabled,
|
||||
set_service_checks_enabled,
|
||||
set_zigbee_sync_enabled,
|
||||
set_zwave_sync_enabled,
|
||||
start_scheduler,
|
||||
stop_scheduler,
|
||||
)
|
||||
from app.db.database import Base
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_node(**kwargs) -> Node:
|
||||
def _make_device(**kwargs) -> InventoryDevice:
|
||||
"""An inventory row — what the checker iterates now that a device is checked
|
||||
once rather than once per canvas."""
|
||||
defaults = dict(id=str(uuid.uuid4()), status="approved", status_live="unknown")
|
||||
return InventoryDevice(**{**defaults, **kwargs})
|
||||
|
||||
|
||||
def _make_node(device_id: str | None = None, **kwargs) -> Node:
|
||||
defaults = dict(
|
||||
id=str(uuid.uuid4()),
|
||||
type="server",
|
||||
label="Test",
|
||||
status="unknown",
|
||||
device_id=device_id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
@@ -41,11 +65,10 @@ async def mem_db():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
|
||||
"""Nodes with no check_method are skipped; check_node is never called."""
|
||||
async def test_run_status_checks_skips_devices_without_check_method(mem_db):
|
||||
"""Devices with no check_method are skipped; check_node is never called."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method=None, ip="10.0.0.1")
|
||||
session.add(node)
|
||||
session.add(_make_device(check_method=None, ip="10.0.0.1"))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
@@ -55,13 +78,16 @@ async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_updates_node_status(mem_db):
|
||||
"""check_node result is persisted to the DB and broadcast via WebSocket."""
|
||||
async def test_run_status_checks_updates_device_status(mem_db):
|
||||
"""check_node result lands on the device row and is broadcast to its nodes."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method="ping", ip="10.0.0.1")
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1")
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
node = _make_node(device_id=device.id)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
device_id, node_id = device.id, node.id
|
||||
|
||||
check_result = {"status": "online", "response_time_ms": 5}
|
||||
|
||||
@@ -70,28 +96,74 @@ async def test_run_status_checks_updates_node_status(mem_db):
|
||||
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
|
||||
await _run_status_checks()
|
||||
|
||||
# Verify DB updated
|
||||
# Verify the row updated — `status_live` is reachability, `status` is the
|
||||
# pending/approved/hidden lifecycle and must not be touched.
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(Node, node_id)
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.status == "online"
|
||||
assert updated.status_live == "online"
|
||||
assert updated.status == "approved"
|
||||
assert updated.response_time_ms == 5
|
||||
|
||||
# Verify WebSocket broadcast
|
||||
# Verify WebSocket broadcast, addressed by device and carrying its nodes
|
||||
mock_broadcast.assert_awaited_once()
|
||||
_, kwargs = mock_broadcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
assert kwargs["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == [node_id]
|
||||
assert kwargs["status"] == "online"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_covers_a_device_no_canvas_draws(mem_db):
|
||||
"""Deliberate: the inventory row, not a node, is what gets monitored.
|
||||
|
||||
A rack mount or an inventory-only entry reports live status without being
|
||||
drawn on any canvas, and deleting a node does not silently stop monitoring
|
||||
the host. `hide` (or clearing the check method) is what ends the checks.
|
||||
"""
|
||||
async with mem_db() as session:
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1")
|
||||
session.add(device)
|
||||
await session.commit()
|
||||
device_id = device.id
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_node", new_callable=AsyncMock,
|
||||
return_value={"status": "online", "response_time_ms": 5}), \
|
||||
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
|
||||
await _run_status_checks()
|
||||
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.status_live == "online"
|
||||
|
||||
_, kwargs = mock_broadcast.call_args
|
||||
assert kwargs["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_skips_a_hidden_device(mem_db):
|
||||
"""Hiding a device is how a user stops the checks, node or no node."""
|
||||
async with mem_db() as session:
|
||||
session.add(_make_device(check_method="ping", ip="10.0.0.1", status="hidden"))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
||||
patch("app.core.scheduler.check_node", new_callable=AsyncMock) as mock_check:
|
||||
await _run_status_checks()
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
|
||||
"""last_seen is updated only when status is 'online'."""
|
||||
async with mem_db() as session:
|
||||
node = _make_node(check_method="ping", ip="10.0.0.1", last_seen=None)
|
||||
session.add(node)
|
||||
device = _make_device(check_method="ping", ip="10.0.0.1", last_seen=None)
|
||||
session.add(device)
|
||||
await session.commit()
|
||||
node_id = node.id
|
||||
device_id = device.id
|
||||
|
||||
check_result = {"status": "offline", "response_time_ms": None}
|
||||
|
||||
@@ -101,18 +173,19 @@ async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
|
||||
await _run_status_checks()
|
||||
|
||||
async with mem_db() as session:
|
||||
updated = await session.get(Node, node_id)
|
||||
updated = await session.get(InventoryDevice, device_id)
|
||||
assert updated is not None
|
||||
assert updated.last_seen is None # not set for offline
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_status_checks_handles_check_error_gracefully(mem_db):
|
||||
"""An exception from check_node is logged and does not abort other nodes."""
|
||||
"""An exception from check_node is logged and does not abort other devices."""
|
||||
async with mem_db() as session:
|
||||
n1 = _make_node(check_method="ping", ip="10.0.0.1")
|
||||
n2 = _make_node(check_method="ping", ip="10.0.0.2")
|
||||
session.add_all([n1, n2])
|
||||
session.add_all([
|
||||
_make_device(check_method="ping", ip="10.0.0.1"),
|
||||
_make_device(check_method="ping", ip="10.0.0.2"),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
call_count = 0
|
||||
@@ -141,6 +214,10 @@ 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
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
start_scheduler()
|
||||
_, kwargs = mock_sched.add_job.call_args
|
||||
assert kwargs["seconds"] == 45
|
||||
@@ -149,9 +226,385 @@ def test_scheduler_uses_settings_interval():
|
||||
def test_start_and_stop_scheduler():
|
||||
"""Scheduler can be started and stopped without errors."""
|
||||
mock_sched = MagicMock()
|
||||
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched), \
|
||||
patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.status_checker_interval = 60
|
||||
mock_settings.service_check_enabled = False
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
start_scheduler()
|
||||
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_device(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_device(mem_db):
|
||||
async with mem_db() as session:
|
||||
device = _make_device(
|
||||
ip="10.0.0.5",
|
||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
||||
)
|
||||
session.add(device)
|
||||
await session.flush()
|
||||
node = _make_node(device_id=device.id)
|
||||
session.add(node)
|
||||
await session.commit()
|
||||
device_id, node_id = device.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["device_id"] == device_id
|
||||
assert kwargs["node_ids"] == [node_id]
|
||||
assert kwargs["services"] == statuses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_skips_devices_without_services(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_device(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
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
mock_settings.zwave_sync_enabled = False
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxmox auto-sync job
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_set_proxmox_sync_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.proxmox_sync_interval = 3600
|
||||
mock_sched.get_job.return_value = None
|
||||
set_proxmox_sync_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_proxmox_sync_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("proxmox_sync")
|
||||
|
||||
|
||||
def test_reschedule_proxmox_sync_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_proxmox_sync(60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_proxmox_sync_skips_when_disabled():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
# Must return before importing/fetching anything.
|
||||
await _run_proxmox_sync()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_proxmox_sync_skips_when_no_token():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.proxmox_sync_enabled = True
|
||||
mock_settings.proxmox_host = "pve"
|
||||
mock_settings.proxmox_token_id = ""
|
||||
mock_settings.proxmox_token_secret = ""
|
||||
await _run_proxmox_sync() # no exception, no fetch
|
||||
|
||||
|
||||
def _proxmox_settings(mock_settings):
|
||||
mock_settings.proxmox_sync_enabled = True
|
||||
mock_settings.proxmox_host = "pve"
|
||||
mock_settings.proxmox_port = 8006
|
||||
mock_settings.proxmox_token_id = "root@pam!tok"
|
||||
mock_settings.proxmox_token_secret = "secret"
|
||||
mock_settings.proxmox_verify_tls = False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_proxmox_sync_records_scan_run(mem_db):
|
||||
"""Auto-sync must create a ScanRun (kind=proxmox) so it shows in Scan
|
||||
history, then delegate to the shared background import with the run id."""
|
||||
from app.db.models import ScanRun
|
||||
|
||||
with (
|
||||
patch("app.core.scheduler.settings") as mock_settings,
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||
patch(
|
||||
"app.api.routes.proxmox._background_proxmox_import",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_bg,
|
||||
):
|
||||
_proxmox_settings(mock_settings)
|
||||
await _run_proxmox_sync()
|
||||
|
||||
# A ScanRun row exists — the missing scan-history trace.
|
||||
async with mem_db() as db:
|
||||
from sqlalchemy import select
|
||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||
assert run.kind == "proxmox"
|
||||
assert run.ranges == ["pve:8006"]
|
||||
|
||||
# Delegated to the same background flow the manual /sync-now uses, passing
|
||||
# the run id + env connection settings.
|
||||
mock_bg.assert_awaited_once_with(
|
||||
run.id, "pve", 8006, "root@pam!tok", "secret", False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_set_zigbee_sync_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.zigbee_sync_interval = 3600
|
||||
mock_sched.get_job.return_value = None
|
||||
set_zigbee_sync_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_zigbee_sync_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
|
||||
|
||||
|
||||
def test_set_zwave_sync_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.zwave_sync_interval = 3600
|
||||
mock_sched.get_job.return_value = None
|
||||
set_zwave_sync_enabled(True)
|
||||
mock_sched.add_job.assert_called_once()
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
set_zwave_sync_enabled(False)
|
||||
mock_sched.remove_job.assert_called_once_with("zwave_sync")
|
||||
|
||||
|
||||
def test_reschedule_zigbee_sync_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_zigbee_sync(60)
|
||||
|
||||
|
||||
def test_reschedule_zwave_sync_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_zwave_sync(60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_skips_when_disabled():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zigbee_sync_enabled = False
|
||||
await _run_zigbee_sync() # returns before importing/fetching anything
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_skips_when_no_host():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_mqtt_host = ""
|
||||
await _run_zigbee_sync() # no exception, no fetch
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zwave_sync_skips_when_no_host():
|
||||
with patch("app.core.scheduler.settings") as mock_settings:
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_mqtt_host = ""
|
||||
await _run_zwave_sync()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zigbee_sync_records_scan_run(mem_db):
|
||||
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
|
||||
then delegate to the shared background import with the run id + env payload."""
|
||||
from app.db.models import ScanRun
|
||||
from app.schemas.zigbee import ZigbeeImportRequest
|
||||
|
||||
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||
|
||||
with (
|
||||
patch("app.core.scheduler.settings") as mock_settings,
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
|
||||
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
|
||||
):
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_mqtt_host = "broker"
|
||||
mock_settings.zigbee_mqtt_port = 1883
|
||||
await _run_zigbee_sync()
|
||||
|
||||
async with mem_db() as db:
|
||||
from sqlalchemy import select
|
||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||
assert run.kind == "zigbee"
|
||||
assert run.ranges == ["broker:1883"]
|
||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_zwave_sync_records_scan_run(mem_db):
|
||||
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
|
||||
background import with the run id + env payload."""
|
||||
from app.db.models import ScanRun
|
||||
from app.schemas.zwave import ZwaveImportRequest
|
||||
|
||||
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||
|
||||
with (
|
||||
patch("app.core.scheduler.settings") as mock_settings,
|
||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
|
||||
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
|
||||
):
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_mqtt_host = "broker"
|
||||
mock_settings.zwave_mqtt_port = 1883
|
||||
await _run_zwave_sync()
|
||||
|
||||
async with mem_db() as db:
|
||||
from sqlalchemy import select
|
||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||
assert run.kind == "zwave"
|
||||
assert run.ranges == ["broker:1883"]
|
||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||
|
||||
|
||||
def test_reschedule_zigbee_sync_noop_when_not_running():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_zigbee_sync(600)
|
||||
mock_sched.reschedule_job.assert_not_called()
|
||||
|
||||
|
||||
def test_start_scheduler_adds_mesh_jobs_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 = False
|
||||
mock_settings.proxmox_sync_enabled = False
|
||||
mock_settings.zigbee_sync_enabled = True
|
||||
mock_settings.zigbee_sync_interval = 3600
|
||||
mock_settings.zwave_sync_enabled = True
|
||||
mock_settings.zwave_sync_interval = 3600
|
||||
start_scheduler()
|
||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||
assert "zigbee_sync" in job_ids
|
||||
assert "zwave_sync" in job_ids
|
||||
|
||||
|
||||
# --- reschedule_* validation and not-running guards ---
|
||||
|
||||
def test_reschedule_status_checks_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_status_checks(5)
|
||||
|
||||
|
||||
def test_reschedule_status_checks_noop_when_not_running():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_status_checks(60)
|
||||
mock_sched.reschedule_job.assert_not_called()
|
||||
|
||||
|
||||
def test_reschedule_status_checks_updates_running_job():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_status_checks(60)
|
||||
mock_sched.reschedule_job.assert_called_once_with(
|
||||
"status_checks", trigger="interval", seconds=60,
|
||||
)
|
||||
|
||||
|
||||
def test_reschedule_service_checks_rejects_short_interval():
|
||||
with pytest.raises(ValueError):
|
||||
reschedule_service_checks(10)
|
||||
|
||||
|
||||
def test_reschedule_service_checks_updates_when_job_exists():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = True
|
||||
mock_sched.get_job.return_value = MagicMock()
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_service_checks(60)
|
||||
mock_sched.reschedule_job.assert_called_once_with(
|
||||
"service_checks", trigger="interval", seconds=60,
|
||||
)
|
||||
|
||||
|
||||
def test_reschedule_proxmox_sync_noop_when_not_running():
|
||||
mock_sched = MagicMock()
|
||||
mock_sched.running = False
|
||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||
reschedule_proxmox_sync(600)
|
||||
mock_sched.reschedule_job.assert_not_called()
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""Tests for GET/POST /api/v1/settings."""
|
||||
import json
|
||||
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}"}
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -41,7 +37,221 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
||||
assert res.json()["interval_seconds"] == 120
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_reschedules_status_checks(client: AsyncClient, headers):
|
||||
"""The new interval must reach the running scheduler, not just the config
|
||||
file: otherwise the UI and scan_config.json show the new value while the
|
||||
job keeps firing at the old one until the backend restarts."""
|
||||
with patch("app.api.routes.settings.settings") as mock_settings, \
|
||||
patch("app.api.routes.settings.reschedule_status_checks") as mock_reschedule:
|
||||
mock_settings.save_overrides = lambda: None
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 120},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
mock_reschedule.assert_called_once_with(120)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_rejects_too_short_status_interval(client: AsyncClient, headers):
|
||||
res = await client.post(
|
||||
"/api/v1/settings",
|
||||
json={"interval_seconds": 5},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
|
||||
def test_proxmox_connection_config_is_env_only_never_from_overrides(tmp_path):
|
||||
"""Connection config (host/port/verify_tls) is env-only: a stale value in
|
||||
scan_config.json must be ignored so it can never clobber the env. Only the
|
||||
auto-sync activation is read back."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.proxmox_host = "pve.local" # as if set from env
|
||||
s.proxmox_port = 8006
|
||||
s.proxmox_verify_tls = True
|
||||
(tmp_path / "scan_config.json").write_text(json.dumps({
|
||||
"proxmox_host": "stale-host",
|
||||
"proxmox_port": 9999,
|
||||
"proxmox_verify_tls": False,
|
||||
"proxmox_sync_enabled": True,
|
||||
"proxmox_sync_interval": 600,
|
||||
}))
|
||||
s.load_overrides()
|
||||
# Connection config untouched by the file (env values survive).
|
||||
assert s.proxmox_host == "pve.local"
|
||||
assert s.proxmox_port == 8006
|
||||
assert s.proxmox_verify_tls is True
|
||||
# Auto-sync activation is the only thing loaded.
|
||||
assert s.proxmox_sync_enabled is True
|
||||
assert s.proxmox_sync_interval == 600
|
||||
|
||||
|
||||
def test_save_overrides_omits_proxmox_connection_config(tmp_path):
|
||||
"""save_overrides must never write host/port/verify_tls (nor the token) —
|
||||
only the sync activation. This is what prevents the dual source of truth."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.proxmox_host = "pve.local"
|
||||
s.proxmox_sync_enabled = True
|
||||
s.proxmox_sync_interval = 900
|
||||
s.save_overrides()
|
||||
written = json.loads((tmp_path / "scan_config.json").read_text())
|
||||
assert "proxmox_host" not in written
|
||||
assert "proxmox_port" not in written
|
||||
assert "proxmox_verify_tls" not in written
|
||||
assert "proxmox_token_id" not in written
|
||||
assert "proxmox_token_secret" not in written
|
||||
assert written["proxmox_sync_enabled"] is True
|
||||
assert written["proxmox_sync_interval"] == 900
|
||||
|
||||
|
||||
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
|
||||
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
|
||||
env-only: stale values in scan_config.json must be ignored. Only the
|
||||
auto-sync activation is read back."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.zigbee_mqtt_host = "broker.local"
|
||||
s.zwave_mqtt_host = "broker.local"
|
||||
(tmp_path / "scan_config.json").write_text(json.dumps({
|
||||
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
|
||||
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
|
||||
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
|
||||
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
|
||||
}))
|
||||
s.load_overrides()
|
||||
assert s.zigbee_mqtt_host == "broker.local"
|
||||
assert s.zwave_mqtt_host == "broker.local"
|
||||
assert s.zigbee_sync_enabled is True
|
||||
assert s.zigbee_sync_interval == 600
|
||||
assert s.zwave_sync_enabled is True
|
||||
assert s.zwave_sync_interval == 700
|
||||
|
||||
|
||||
def test_save_overrides_omits_mesh_credentials(tmp_path):
|
||||
"""save_overrides must never write MQTT host/credentials — only the sync
|
||||
activation. Prevents the dual source of truth and leaking secrets to disk."""
|
||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
||||
s.zigbee_mqtt_host = "broker.local"
|
||||
s.zigbee_mqtt_password = "secret"
|
||||
s.zigbee_sync_enabled = True
|
||||
s.zigbee_sync_interval = 900
|
||||
s.zwave_mqtt_password = "secret"
|
||||
s.zwave_sync_interval = 1200
|
||||
s.save_overrides()
|
||||
raw = (tmp_path / "scan_config.json").read_text()
|
||||
assert "secret" not in raw
|
||||
written = json.loads(raw)
|
||||
assert "zigbee_mqtt_host" not in written
|
||||
assert "zigbee_mqtt_password" not in written
|
||||
assert "zwave_mqtt_password" not in written
|
||||
assert written["zigbee_sync_enabled"] is True
|
||||
assert written["zigbee_sync_interval"] == 900
|
||||
assert written["zwave_sync_interval"] == 1200
|
||||
|
||||
|
||||
def _valid_oidc_settings(**overrides):
|
||||
values = {
|
||||
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "client-secret",
|
||||
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
|
||||
"cors_origins": ["https://homelable.example"],
|
||||
}
|
||||
values.update(overrides)
|
||||
return Settings(_env_file=None, **values)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_complete_client_configuration():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
|
||||
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
|
||||
|
||||
|
||||
def test_oidc_mode_requires_openid_scope():
|
||||
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
|
||||
_valid_oidc_settings(oidc_scopes="profile email")
|
||||
|
||||
|
||||
def test_oidc_mode_requires_strong_session_secret():
|
||||
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
||||
_valid_oidc_settings(secret_key="too-short")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_callback():
|
||||
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
|
||||
|
||||
|
||||
def test_oidc_secure_cookie_requires_https_discovery():
|
||||
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
|
||||
_valid_oidc_settings(
|
||||
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
|
||||
)
|
||||
|
||||
|
||||
def test_oidc_insecure_dev_mode_allows_http_urls():
|
||||
configured = _valid_oidc_settings(
|
||||
oidc_cookie_secure=False,
|
||||
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
|
||||
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
|
||||
)
|
||||
assert configured.oidc_cookie_secure is False
|
||||
|
||||
|
||||
def test_oidc_mode_rejects_wildcard_cors():
|
||||
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
|
||||
_valid_oidc_settings(cors_origins=["*"])
|
||||
|
||||
|
||||
def test_valid_oidc_configuration_is_accepted():
|
||||
configured = _valid_oidc_settings()
|
||||
assert configured.auth_mode == "oidc"
|
||||
assert configured.oidc_cookie_secure is True
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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", "kvm", "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", ["PiKVM", "TinyPilot", "JetKVM", "NanoKVM"])
|
||||
def test_ip_kvm_titles_suggest_kvm_node_type(title):
|
||||
sig = match_service(80, "tcp", banner=None, http_signals={"title": title, "headers": {}})
|
||||
assert sig is not None
|
||||
assert sig["suggested_node_type"] == "kvm"
|
||||
|
||||
|
||||
@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
|
||||
+18
-10
@@ -9,7 +9,7 @@ from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -68,16 +68,24 @@ async def test_summary_aggregates_counts(
|
||||
) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||
# Reachability and the ieee live on the inventory row each node draws.
|
||||
drawn = [
|
||||
("A", "server", "online", None),
|
||||
("B", "server", "online", None),
|
||||
("C", "server", "offline", None),
|
||||
("D", "server", "unknown", None),
|
||||
("Z1", "iot", "online", "0x1"),
|
||||
("Z2", "iot", "online", "0x2"),
|
||||
]
|
||||
for label, node_type, status_live, ieee in drawn:
|
||||
device = InventoryDevice(status="approved", status_live=status_live, ieee_address=ieee)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(type=node_type, label=label, device_id=device.id))
|
||||
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
|
||||
InventoryDevice(ip="10.0.0.1", status="pending"),
|
||||
InventoryDevice(ip="10.0.0.2", status="pending"),
|
||||
InventoryDevice(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)),
|
||||
|
||||
@@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,7 +74,8 @@ async def test_broadcast_status_sends_to_connected_clients():
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_status(
|
||||
node_id="node-1",
|
||||
device_id="dev-1",
|
||||
node_ids=["node-1", "node-2"],
|
||||
status="online",
|
||||
checked_at="2024-01-01T00:00:00",
|
||||
response_time_ms=42,
|
||||
@@ -79,7 +86,9 @@ async def test_broadcast_status_sends_to_connected_clients():
|
||||
assert len(received) == 1
|
||||
msg = json.loads(received[0])
|
||||
assert msg["type"] == "status"
|
||||
assert msg["node_id"] == "node-1"
|
||||
# One check, addressed by device, lighting up every canvas drawing it.
|
||||
assert msg["device_id"] == "dev-1"
|
||||
assert msg["node_ids"] == ["node-1", "node-2"]
|
||||
assert msg["status"] == "online"
|
||||
assert msg["response_time_ms"] == 42
|
||||
|
||||
@@ -96,7 +105,7 @@ async def test_broadcast_status_no_response_time():
|
||||
fake = FakeWS()
|
||||
_connections.append(fake)
|
||||
try:
|
||||
await broadcast_status(node_id="n", status="offline", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="offline", checked_at="t")
|
||||
finally:
|
||||
_connections.remove(fake)
|
||||
|
||||
@@ -116,7 +125,7 @@ async def test_broadcast_status_removes_dead_connection():
|
||||
_connections.append(dead)
|
||||
initial_len = len(_connections)
|
||||
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
|
||||
assert dead not in _connections
|
||||
assert len(_connections) == initial_len - 1
|
||||
@@ -153,5 +162,67 @@ async def test_broadcast_scan_update():
|
||||
async def test_broadcast_no_connections():
|
||||
"""broadcast_* with no connections must not raise."""
|
||||
assert len(_connections) == 0
|
||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||
await broadcast_status(device_id="d", node_ids=["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(
|
||||
device_id="dev-7",
|
||||
node_ids=["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["device_id"] == "dev-7"
|
||||
assert msg["node_ids"] == ["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(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
# A second broadcast must not raise even though dead is already gone.
|
||||
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
|
||||
assert dead not in _connections
|
||||
|
||||
@@ -3,7 +3,26 @@ 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 (
|
||||
_http_get,
|
||||
_ping,
|
||||
_tcp_connect,
|
||||
check_node,
|
||||
check_service,
|
||||
check_services,
|
||||
)
|
||||
|
||||
|
||||
def _mock_httpx_client(status_code):
|
||||
"""Build a stand-in for httpx.AsyncClient whose GET returns status_code."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=client)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return MagicMock(return_value=ctx)
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@@ -169,9 +188,14 @@ async def test_ping_uses_unix_args_on_non_windows():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
assert "-n" not in captured["args"]
|
||||
# Linux: -W is in seconds; 1s is the intended timeout
|
||||
# 2 probes so a single dropped packet doesn't flap the node offline
|
||||
c_idx = captured["args"].index("-c")
|
||||
assert captured["args"][c_idx + 1] == "2"
|
||||
# Linux: -W is in seconds; 2s is the intended timeout
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1"
|
||||
assert captured["args"][w_idx + 1] == "2"
|
||||
# IPv4 target → no -6 flag
|
||||
assert "-6" not in captured["args"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -193,7 +217,7 @@ async def test_ping_uses_macos_millisecond_timeout():
|
||||
assert "-c" in captured["args"]
|
||||
assert "-W" in captured["args"]
|
||||
w_idx = captured["args"].index("-W")
|
||||
assert captured["args"][w_idx + 1] == "1000"
|
||||
assert captured["args"][w_idx + 1] == "2000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -216,6 +240,75 @@ async def test_ping_uses_windows_args_on_win32():
|
||||
assert "-c" not in captured["args"]
|
||||
|
||||
|
||||
# --- _ping IPv6 support ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_linux_uses_dash6():
|
||||
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("fe80::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
assert captured["args"][-1] == "fe80::1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_macos_uses_ping6():
|
||||
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert captured["args"][0] == "ping6"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_ipv6_windows_uses_dash6():
|
||||
captured = {}
|
||||
|
||||
async def fake_exec(*args, **kwargs):
|
||||
captured["args"] = args
|
||||
proc = MagicMock()
|
||||
proc.returncode = 0
|
||||
proc.wait = AsyncMock()
|
||||
return proc
|
||||
|
||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
||||
await _ping("2001:db8::1")
|
||||
|
||||
assert "-6" in captured["args"]
|
||||
|
||||
|
||||
def test_is_ipv6_detection():
|
||||
from app.services.status_checker import _is_ipv6
|
||||
|
||||
assert _is_ipv6("fe80::1") is True
|
||||
assert _is_ipv6("2001:db8::1") is True
|
||||
assert _is_ipv6("[2001:db8::1]") is True
|
||||
assert _is_ipv6("192.168.1.1") is False
|
||||
assert _is_ipv6("example.local") is False
|
||||
|
||||
|
||||
# --- check_node target validation ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -268,3 +361,224 @@ 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", "host": None, "status": "online"},
|
||||
{"port": 5432, "protocol": "tcp", "host": None, "status": "unknown"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_echoes_the_host_override():
|
||||
services = [{"port": 443, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}]
|
||||
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": 443, "protocol": "tcp", "host": "blog.example.com", "status": "online"},
|
||||
]
|
||||
|
||||
|
||||
# --- Per-service host override ---
|
||||
|
||||
|
||||
async def _captured_url(svc, host):
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_service(svc, host)
|
||||
return captured.get("url")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_host_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_falls_back_to_the_node_host_when_override_is_blank():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": " "}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://10.0.0.1:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_honours_a_scheme_in_the_override():
|
||||
svc = {"protocol": "tcp", "service_name": "blog", "host": "https://blog.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_takes_the_port_from_the_override():
|
||||
svc = {"protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com:8443"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_lets_the_service_port_beat_the_override_port():
|
||||
svc = {"port": 3000, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:3000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_first_host_of_a_comma_list_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com, alt.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_brackets_an_ipv6_override():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http", "host": "[2001:db8::1]:8080"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://[2001:db8::1]:80"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_skips_a_non_http_port_behind_an_override():
|
||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres", "host": "db.example.com"}
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_get:
|
||||
assert await check_service(svc, "10.0.0.1") == "unknown"
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_services_empty_list():
|
||||
assert await check_services("10.0.0.1", []) == []
|
||||
|
||||
|
||||
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_get_true_on_2xx():
|
||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
|
||||
assert await _http_get("http://host") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_get_true_on_4xx():
|
||||
# A 4xx means the server is up and answering — still "online".
|
||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(404)):
|
||||
assert await _http_get("http://host") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_get_false_on_5xx():
|
||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(503)):
|
||||
assert await _http_get("http://host") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_http_exception_returns_offline():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||
with patch(
|
||||
"app.services.status_checker._http_get",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("connection refused"),
|
||||
):
|
||||
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
@@ -11,13 +11,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -338,20 +331,36 @@ async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_creates_coordinator_and_pending(
|
||||
async def test_persist_pending_import_coordinator_goes_to_pending(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
assert result.device_count == 3
|
||||
assert result.pending_created == 2
|
||||
assert result.pending_created == 3 # coordinator included now
|
||||
assert result.pending_updated == 0
|
||||
assert result.coordinator is not None
|
||||
assert result.coordinator.ieee_address == "0xCOORD"
|
||||
assert result.coordinator is None # not auto-placed
|
||||
assert result.coordinator_already_existed is False
|
||||
assert result.links_recorded == 2
|
||||
|
||||
# No canvas Node auto-created for the coordinator.
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert nodes == []
|
||||
# Coordinator sits in the pending inventory.
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.status == "pending"
|
||||
assert coord.suggested_type == "zigbee_coordinator"
|
||||
assert coord.device_subtype == "Coordinator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_idempotent_updates_existing(
|
||||
@@ -366,8 +375,8 @@ async def test_persist_pending_import_idempotent_updates_existing(
|
||||
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.pending_updated == 3 # coordinator upserts too
|
||||
assert result.coordinator_already_existed is False
|
||||
assert result.links_recorded == 2
|
||||
|
||||
|
||||
@@ -376,25 +385,25 @@ 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
|
||||
from app.db.models import InventoryDeviceLink
|
||||
|
||||
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()
|
||||
rows = (await db_session.execute(select(InventoryDeviceLink))).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."""
|
||||
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
|
||||
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||
nodes_with_meta[0]["vendor"] = "TI"
|
||||
@@ -403,37 +412,42 @@ async def test_persist_pending_import_sets_coordinator_properties(db_session) ->
|
||||
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.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)
|
||||
assert coord.vendor == "TI"
|
||||
assert coord.model == "CC2652"
|
||||
assert coord.suggested_type == "zigbee_coordinator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||
async def test_persist_pending_import_backfills_inventory_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.
|
||||
"""A device on canvas but missing its inventory row gets one backfilled
|
||||
(status="approved"), so it shows in the inventory list. Node props still
|
||||
refresh 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
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# Simulate: router was approved earlier → exists as a canvas Node.
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
# Simulate: router was approved earlier → exists as a canvas Node, but with
|
||||
# no matching device_inventory row (e.g. a legacy auto-placed device).
|
||||
device = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
status="approved",
|
||||
status_live="online",
|
||||
check_method="none",
|
||||
properties=[],
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.flush()
|
||||
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -441,17 +455,21 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||
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 = (
|
||||
# An inventory row is backfilled as "approved" (it is on a canvas).
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalars().all()
|
||||
assert pendings == []
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
assert inv.suggested_type == "zigbee_router"
|
||||
assert inv.device_subtype == "Router"
|
||||
|
||||
# Node properties got refreshed.
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.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"}
|
||||
@@ -459,27 +477,148 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
||||
assert all(p["visible"] is False for p in refreshed.properties)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_preserves_hidden_inventory_for_approved_node(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""If the on-canvas device already has a hidden inventory row, re-import
|
||||
refreshes its metadata but must NOT flip it back to approved/visible."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
# One row, hidden by the user, and a canvas still draws it.
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
||||
device_subtype="Router", status="hidden", status_live="online",
|
||||
check_method="none", discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="router_1", type="zigbee_router", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "hidden" # stays hidden
|
||||
|
||||
|
||||
@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 (InventoryDevice.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 InventoryDevice
|
||||
|
||||
# Simulate prior approve: a InventoryDevice marked approved, but NO matching
|
||||
# Node exists (the user deleted the canvas node afterwards).
|
||||
orphan = InventoryDevice(
|
||||
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(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
# Coordinator + end device 0xE1 are brand new → created; router was revived.
|
||||
assert result.pending_created == 2
|
||||
assert result.pending_updated == 1
|
||||
|
||||
# It is now visible to the Pending list (status filter == "pending").
|
||||
listed = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalars().all()
|
||||
assert {p.ieee_address for p in listed} == {"0xCOORD", "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 InventoryDevice
|
||||
|
||||
hidden = InventoryDevice(
|
||||
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(InventoryDevice).where(InventoryDevice.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
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
approved = Node(
|
||||
label="router_1",
|
||||
type="zigbee_router",
|
||||
status="online",
|
||||
check_method="none",
|
||||
device = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
services=[],
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
status="approved",
|
||||
status_live="online",
|
||||
check_method="none",
|
||||
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(device)
|
||||
await db_session.flush()
|
||||
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
|
||||
db_session.add(approved)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -489,7 +628,9 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
refreshed = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
|
||||
)
|
||||
).scalar_one()
|
||||
by_key = {p["key"]: p for p in refreshed.properties}
|
||||
# Existing keys keep their visibility (True).
|
||||
@@ -505,15 +646,27 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||
async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""An already-approved coordinator Node gets its props refreshed on
|
||||
re-import, and a backfilled inventory row (approved) so it shows in the
|
||||
inventory list — this is the legacy auto-placed coordinator scenario."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Node
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xCOORD", friendly_name="Coordinator",
|
||||
suggested_type="zigbee_coordinator", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="Coordinator", type="zigbee_coordinator", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[0]["vendor"] = "TI"
|
||||
@@ -521,15 +674,64 @@ async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
coord = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.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
|
||||
# Inventory row backfilled as approved → now visible in the inventory list.
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
assert inv.suggested_type == "zigbee_coordinator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_pending_import_device_on_multiple_canvases(
|
||||
db_session,
|
||||
) -> None:
|
||||
"""Regression: a device approved onto TWO designs (one Node each) must not
|
||||
crash re-import with MultipleResultsFound — props refresh on both nodes."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zigbee import _persist_pending_import
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
||||
status="approved", status_live="online", check_method="none",
|
||||
)
|
||||
db_session.add_all([d1, d2, drawn])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="router_1", type="zigbee_router", device_id=drawn.id, design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["lqi"] = 240
|
||||
# Must not raise.
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
# One row behind them, so one refresh serves both canvases.
|
||||
await db_session.refresh(drawn)
|
||||
for n in [drawn]:
|
||||
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
|
||||
assert lqi == "240" # refreshed on every canvas
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -559,3 +761,106 @@ async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> N
|
||||
kwargs = mock_conn.call_args.kwargs
|
||||
assert kwargs["tls"] is True
|
||||
assert kwargs["tls_insecure"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zigbee/config + /sync-now (auto-sync)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _restore_zigbee_env():
|
||||
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
|
||||
keys = (
|
||||
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
|
||||
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
|
||||
"zigbee_sync_interval",
|
||||
)
|
||||
saved = {k: getattr(settings, k) for k in keys}
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
setattr(settings, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_omits_credentials(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
settings.zigbee_mqtt_username = "user"
|
||||
settings.zigbee_mqtt_password = "supersecret"
|
||||
res = await client.get("/api/v1/zigbee/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert "supersecret" not in res.text
|
||||
assert "user" not in res.text
|
||||
assert res.json()["host_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/zigbee/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_sync_without_host_rejected(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = ""
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/config",
|
||||
json={"sync_enabled": True, "sync_interval": 600},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_config_persists_only_sync_fields(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
saved: dict = {}
|
||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
||||
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
|
||||
interval=self.zigbee_sync_interval,
|
||||
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
|
||||
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
|
||||
res = await client.post(
|
||||
"/api/v1/zigbee/config",
|
||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert settings.zigbee_mqtt_host == "broker" # body host ignored
|
||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_creates_scan_run(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = "broker"
|
||||
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
|
||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "zigbee"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_rejected_without_host(
|
||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
||||
) -> None:
|
||||
settings.zigbee_mqtt_host = ""
|
||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zigbee/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
"""API endpoint tests for /api/v1/zwave/*."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /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_coordinator_goes_to_pending(db_session) -> None:
|
||||
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
assert result.device_count == 3
|
||||
assert result.pending_created == 3 # coordinator included now
|
||||
assert result.pending_updated == 0
|
||||
assert result.coordinator is None # not auto-placed
|
||||
assert result.coordinator_already_existed is False
|
||||
assert result.links_recorded == 2
|
||||
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert nodes == []
|
||||
coord = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.status == "pending"
|
||||
assert coord.suggested_type == "zwave_coordinator"
|
||||
|
||||
|
||||
@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 == 3 # coordinator upserts too
|
||||
assert result.coordinator_already_existed is False
|
||||
|
||||
|
||||
@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 InventoryDeviceLink
|
||||
|
||||
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(InventoryDeviceLink))).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_pending_fields(db_session) -> None:
|
||||
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
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(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-1")
|
||||
)
|
||||
).scalar_one()
|
||||
assert coord.vendor == "Aeotec"
|
||||
assert coord.model == "ZW090"
|
||||
assert coord.suggested_type == "zwave_coordinator"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_backfills_inventory_for_approved_node(db_session) -> None:
|
||||
"""On-canvas device missing its inventory row gets one backfilled
|
||||
(status="approved"); Node props still refresh."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
)
|
||||
db_session.add(drawn)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(label="Wall Plug", type="zwave_router", device_id=drawn.id))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||
|
||||
inv = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
assert inv.suggested_type == "zwave_router"
|
||||
refreshed = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.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 InventoryDevice
|
||||
|
||||
orphan = InventoryDevice(
|
||||
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(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert revived.status == "pending"
|
||||
# Coordinator + end device are brand new → created; router was revived.
|
||||
assert result.pending_created == 2
|
||||
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 InventoryDevice
|
||||
|
||||
hidden = InventoryDevice(
|
||||
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(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
|
||||
)
|
||||
).scalar_one()
|
||||
assert still_hidden.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
||||
"""Regression: a device on TWO designs (one Node each) must not crash
|
||||
re-import with MultipleResultsFound — props refresh on both nodes."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.routes.zwave import _persist_pending_import
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
d1 = Design(name="d1")
|
||||
d2 = Design(name="d2")
|
||||
drawn = InventoryDevice(
|
||||
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
|
||||
suggested_type="zwave_router", status="approved", status_live="online",
|
||||
check_method="none",
|
||||
)
|
||||
db_session.add_all([d1, d2, drawn])
|
||||
await db_session.flush()
|
||||
for d in (d1, d2):
|
||||
db_session.add(Node(
|
||||
label="Wall Plug", type="zwave_router", device_id=drawn.id, design_id=d.id,
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
bumped = [dict(n) for n in _PENDING_NODES]
|
||||
bumped[1]["model"] = "ZW200"
|
||||
# Must not raise.
|
||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2 # both canvas placements preserved
|
||||
# One row behind them, so one refresh serves every canvas.
|
||||
await db_session.refresh(drawn)
|
||||
model = {p["key"]: p["value"] for p in drawn.properties}.get("Model")
|
||||
assert model == "ZW200"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /api/v1/zwave/config + /sync-now (auto-sync)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from unittest.mock import AsyncMock # noqa: E402
|
||||
|
||||
from app.core.config import settings # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _restore_zwave_env():
|
||||
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
|
||||
keys = (
|
||||
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
|
||||
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
|
||||
"zwave_sync_enabled", "zwave_sync_interval",
|
||||
)
|
||||
saved = {k: getattr(settings, k) for k in keys}
|
||||
yield
|
||||
for k, v in saved.items():
|
||||
setattr(settings, k, v)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_omits_credentials(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
settings.zwave_mqtt_username = "user"
|
||||
settings.zwave_mqtt_password = "supersecret"
|
||||
res = await client.get("/api/v1/zwave/config", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert "supersecret" not in res.text
|
||||
assert "user" not in res.text
|
||||
assert res.json()["host_configured"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/zwave/config")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enable_sync_without_host_rejected(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = ""
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/config",
|
||||
json={"sync_enabled": True, "sync_interval": 600},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_config_persists_only_sync_fields(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
saved: dict = {}
|
||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
||||
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
|
||||
interval=self.zwave_sync_interval,
|
||||
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
|
||||
patch("app.api.routes.zwave.reschedule_zwave_sync"):
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/config",
|
||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert settings.zwave_mqtt_host == "broker" # body host ignored
|
||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_creates_scan_run(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = "broker"
|
||||
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["kind"] == "zwave"
|
||||
assert data["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_rejected_without_host(
|
||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
||||
) -> None:
|
||||
settings.zwave_mqtt_host = ""
|
||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zwave/sync-now")
|
||||
assert res.status_code == 401
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user