Compare commits
530
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a58725c2ee | ||
|
|
466df59b0c | ||
|
|
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 | ||
|
|
12d527aad6 | ||
|
|
73b479a408 | ||
|
|
16183285b5 | ||
|
|
3b3cc1eba0 | ||
|
|
3da5517996 | ||
|
|
cb26da3de5 | ||
|
|
23a0a47a7f | ||
|
|
aac6c09a04 | ||
|
|
bf90d6312b | ||
|
|
ec15c260e1 | ||
|
|
47ab3a9a76 | ||
|
|
eb7b0c6d38 | ||
|
|
be320c2ac4 | ||
|
|
60ce648a96 | ||
|
|
d5e35a885d | ||
|
|
3a57d809a4 | ||
|
|
46435605eb | ||
|
|
e8bcf04b46 | ||
|
|
4ba04660c8 | ||
|
|
4b06ce6ef6 | ||
|
|
6761f73c17 | ||
|
|
1431f5b19e | ||
|
|
b5628e18fa | ||
|
|
dc70805673 | ||
|
|
05c24d622b | ||
|
|
541e25327b | ||
|
|
9823be9d78 | ||
|
|
b71c96897a | ||
|
|
20e1820a4e | ||
|
|
517486ff79 | ||
|
|
b5b1056ae6 | ||
|
|
66a9a57861 | ||
|
|
529c75a175 | ||
|
|
fc765fa255 | ||
|
|
77159ce1cd | ||
|
|
69aa8256f0 | ||
|
|
78b43a300f | ||
|
|
995de26591 | ||
|
|
525dfe5ece | ||
|
|
d066f37e88 | ||
|
|
004623bae5 | ||
|
|
f8635df1c5 | ||
|
|
1cc9b7c52f | ||
|
|
fdf2b1f2be | ||
|
|
5630e7d202 | ||
|
|
be705f0cb9 | ||
|
|
1790d24ee7 | ||
|
|
8b97f578c6 | ||
|
|
48e7b3c690 | ||
|
|
3a4df578ec | ||
|
|
43426e96ac | ||
|
|
280d5a2ae1 | ||
|
|
cdc6091bd0 | ||
|
|
14597ab86f | ||
|
|
14719a5c39 | ||
|
|
ea451885af | ||
|
|
9cd93ef294 | ||
|
|
8d937b2a0d | ||
|
|
60ea0fa11b | ||
|
|
ff0d33128c | ||
|
|
17d4ddd070 | ||
|
|
4795dc40b8 | ||
|
|
994ed9d77a | ||
|
|
4d4befa916 | ||
|
|
8f741691d0 | ||
|
|
c21fbac599 | ||
|
|
e9c66fceda | ||
|
|
373960f6ea | ||
|
|
aa17edf1d0 | ||
|
|
5f87c64dcf | ||
|
|
5822a1483a | ||
|
|
3a303a1376 | ||
|
|
f5e0e68806 | ||
|
|
8541922386 | ||
|
|
3ed9cb0d4f | ||
|
|
fff11a4b6a | ||
|
|
890463373a | ||
|
|
0680566081 | ||
|
|
2f5a90a00e | ||
|
|
928f63df0f | ||
|
|
e84a4e0eb3 | ||
|
|
3b0dbd7a8b | ||
|
|
3a9b3b2650 | ||
|
|
ff02f3b5db | ||
|
|
a66e6aa906 | ||
|
|
8e59d9a0d1 | ||
|
|
ee4136b506 | ||
|
|
8e8f01d6ca | ||
|
|
aba4f34168 | ||
|
|
6524efe126 | ||
|
|
062a08f116 | ||
|
|
5e9963db4d | ||
|
|
82203f67d8 | ||
|
|
004a8f19c1 | ||
|
|
5ab0bdeb7f | ||
|
|
3f9866e8a1 | ||
|
|
ebdf6cb55b | ||
|
|
ec8f1c87f1 | ||
|
|
2b91f31807 | ||
|
|
c6965b827b | ||
|
|
afdea65d4e | ||
|
|
32eb9fbe23 | ||
|
|
27fd78ece6 | ||
|
|
e672cb086a | ||
|
|
3cddcf5b81 | ||
|
|
19791abf5b | ||
|
|
d9d4be9a53 | ||
|
|
c165b8b016 | ||
|
|
b16e81cf29 | ||
|
|
3028e5a3f7 | ||
|
|
340bd15099 | ||
|
|
d181cef4c1 | ||
|
|
4a048d0ab5 | ||
|
|
f32c32f6a5 | ||
|
|
d1f170d5db | ||
|
|
0a680fb9f2 | ||
|
|
9db41e886a | ||
|
|
0863c2db94 | ||
|
|
b17299f531 | ||
|
|
277879488f | ||
|
|
456f26a85a | ||
|
|
2a6140a6b1 | ||
|
|
3ae159d8d6 | ||
|
|
5e567d4628 | ||
|
|
05db9a59f5 | ||
|
|
a46e505505 | ||
|
|
5b5866d8d6 | ||
|
|
9970780e7a | ||
|
|
53aeb82af1 | ||
|
|
d49610163f | ||
|
|
517130a9ef | ||
|
|
c90538b1d0 | ||
|
|
67b0620be2 | ||
|
|
cc9c010002 | ||
|
|
103e24e5fa | ||
|
|
8110ee075d | ||
|
|
2994f8653a | ||
|
|
5fb77ab00b | ||
|
|
56cfbd1e76 | ||
|
|
43761c60cb | ||
|
|
ad958feabd | ||
|
|
e3876e934c | ||
|
|
dfa4a9c849 | ||
|
|
785be6a5dd | ||
|
|
d5f4a9f729 | ||
|
|
8859893e42 | ||
|
|
93b98f760c | ||
|
|
30ed78c240 | ||
|
|
39f8d16ef1 | ||
|
|
0095bf8425 | ||
|
|
69436c438c | ||
|
|
2cc97a6de9 | ||
|
|
29a2ef1b20 | ||
|
|
0c836e0575 | ||
|
|
6518eb313b | ||
|
|
09a591f5f4 | ||
|
|
988b804b90 | ||
|
|
6fa0ada325 | ||
|
|
17613f42d1 | ||
|
|
0a44b69c4e | ||
|
|
84235d81bf | ||
|
|
31b61904ac | ||
|
|
babbcb1dc5 | ||
|
|
12fde681ba | ||
|
|
63922f0841 | ||
|
|
896cd4fa21 | ||
|
|
8c9f1a23e0 | ||
|
|
18f9bb7bdf | ||
|
|
da287d459c | ||
|
|
adb2088752 | ||
|
|
cd0e08fb91 | ||
|
|
3ccdde0bea | ||
|
|
d0a49d0a0d | ||
|
|
31b5bc4515 | ||
|
|
b05d70663c | ||
|
|
4e68af7cac | ||
|
|
528c362633 | ||
|
|
bfe520cd49 | ||
|
|
29c97ae501 | ||
|
|
4ecd241bf4 | ||
|
|
96786f155b | ||
|
|
1d6127fed3 | ||
|
|
26633f760d | ||
|
|
745002593f | ||
|
|
1a978c5e51 | ||
|
|
16adff5cff | ||
|
|
45b0965fb7 | ||
|
|
f1bcd6ef78 | ||
|
|
26be37f731 | ||
|
|
bfd7ccd36c | ||
|
|
ada30311ed | ||
|
|
6ead20125d | ||
|
|
8626fb2ca4 | ||
|
|
5952274c27 | ||
|
|
9c035e2be2 | ||
|
|
cad3add223 | ||
|
|
fc888629c3 | ||
|
|
6a21cc729e | ||
|
|
06370529c6 | ||
|
|
892710faac | ||
|
|
431fb47498 |
+73
-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
|
||||
|
||||
@@ -28,3 +58,44 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# Off by default. Set to a random secret to enable.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# LIVEVIEW_KEY=
|
||||
|
||||
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
||||
# Off by default. Set to a random secret to enable; clients must send
|
||||
# the same value in the `X-API-Key` header.
|
||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
# HOMEPAGE_API_KEY=
|
||||
|
||||
# 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)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: pouzor
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke-and-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -16,14 +16,21 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- image: ghcr.io/pouzor/homelable-backend
|
||||
context: .
|
||||
dockerfile: Dockerfile.backend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: ""
|
||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
build_args: "VITE_STANDALONE=true"
|
||||
- image: ghcr.io/pouzor/homelable-mcp
|
||||
context: ./mcp
|
||||
dockerfile: Dockerfile.mcp
|
||||
build_args: ""
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -55,8 +62,8 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.dockerfile }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -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
|
||||
|
||||
+733
@@ -0,0 +1,733 @@
|
||||
# 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.2] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- Devices upgraded from 3.2.0 keep their ip, services, hostname, notes and hardware. The 3.3.0 migration aborted on timestamps SQLite returns as text, so nothing was carried across to the Device Inventory and every device read back blank. (#347, #348)
|
||||
- A canvas saved while an earlier migration was stuck no longer loses its device facts: the migration now fills the blank inventory row that save created, instead of skipping the node and dropping the columns holding its only copy. (#347)
|
||||
|
||||
## [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,13 +1,45 @@
|
||||
# Homelable
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
</p>
|
||||
|
||||
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||
<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.
|
||||
|
||||
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||
|
||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||
|
||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||
|
||||
If you are running <img width="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)
|
||||
|
||||
---
|
||||
|
||||
@@ -15,16 +47,43 @@ If you just like the design, you can only run the frontend and export your desig
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="48%" />
|
||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
||||
<img 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -37,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:
|
||||
@@ -74,6 +147,100 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
||||
|
||||
---
|
||||
|
||||
## Zigbee2MQTT Import
|
||||
|
||||
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
||||
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
||||
|
||||
### Usage
|
||||
|
||||
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
||||
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
||||
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
||||
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
||||
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
||||
|
||||
### Node Types
|
||||
|
||||
| Type | Z2M Device | Icon |
|
||||
|------|-----------|------|
|
||||
| `zigbee_coordinator` | Coordinator | Network hub |
|
||||
| `zigbee_router` | Router (mains-powered) | Radio |
|
||||
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
||||
|
||||
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
||||
LQI (Link Quality Indicator) is stored as a node property.
|
||||
|
||||
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -99,7 +266,61 @@ The page shows your canvas in pan/zoom-only mode — no editing, no credentials
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optionnal)
|
||||
## Gethomepage Widget (read-only stats)
|
||||
|
||||
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
||||
|
||||
### Activation
|
||||
|
||||
Add `HOMEPAGE_API_KEY` to your `.env`:
|
||||
|
||||
`HOMEPAGE_API_KEY=your-secret-key`
|
||||
|
||||
Restart the backend (`docker compose restart backend`).
|
||||
|
||||
### Endpoint
|
||||
|
||||
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": 12,
|
||||
"online": 9,
|
||||
"offline": 2,
|
||||
"unknown": 1,
|
||||
"pending_devices": 3,
|
||||
"zigbee_devices": 5,
|
||||
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### gethomepage `services.yaml` snippet
|
||||
|
||||
```yaml
|
||||
- Homelab:
|
||||
- Homelable:
|
||||
icon: mdi-lan
|
||||
href: http://homelable.local:3000
|
||||
widget:
|
||||
type: customapi
|
||||
url: http://homelable.local:8000/api/v1/stats/summary
|
||||
method: GET
|
||||
headers:
|
||||
X-API-Key: your-secret-key
|
||||
mappings:
|
||||
- field: nodes ; label: Nodes
|
||||
- field: online ; label: Online
|
||||
- field: offline ; label: Offline
|
||||
- field: pending_devices ; label: Pending
|
||||
- field: zigbee_devices ; label: Zigbee
|
||||
- field: last_scan_at ; label: Last scan
|
||||
```
|
||||
|
||||
The backend port (`8000`) must be reachable from your gethomepage container.
|
||||
|
||||
---
|
||||
|
||||
## MCP Server (AI Integration) (optional)
|
||||
|
||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||
|
||||
@@ -134,11 +355,17 @@ docker compose up -d mcp
|
||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||
```
|
||||
|
||||
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
||||
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
||||
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
||||
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
||||
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
||||
|
||||
**3. Configure your AI client:**
|
||||
|
||||
**Claude Code** — run this command in your terminal:
|
||||
```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"
|
||||
```
|
||||
|
||||
@@ -147,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"
|
||||
}
|
||||
@@ -162,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
|
||||
|
||||
@@ -1,79 +1,144 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=CanvasStateResponse)
|
||||
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
async def load_canvas(
|
||||
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> CanvasStateResponse:
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = 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,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def save_canvas(
|
||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
) -> dict[str, bool | str]:
|
||||
design_id = body.design_id
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
|
||||
db.add(new_design)
|
||||
await db.flush()
|
||||
design_id = new_design.id
|
||||
|
||||
incoming_node_ids = {n.id for n in body.nodes}
|
||||
incoming_edge_ids = {e.id for e in body.edges}
|
||||
|
||||
# Delete nodes removed from canvas
|
||||
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
||||
# Delete nodes removed from canvas (only within this design)
|
||||
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for node in existing_nodes:
|
||||
if node.id not in incoming_node_ids:
|
||||
await db.delete(node)
|
||||
|
||||
# Delete edges removed from canvas
|
||||
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
||||
# Delete edges removed from canvas (only within this design)
|
||||
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for edge in existing_edges:
|
||||
if edge.id not in incoming_edge_ids:
|
||||
await db.delete(edge)
|
||||
|
||||
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 node_data.model_dump().items():
|
||||
for field, value in columns.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**node_data.model_dump()))
|
||||
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:
|
||||
db_edge = await db.get(Edge, edge_data.id)
|
||||
payload = edge_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
if db_edge:
|
||||
for field, value in edge_data.model_dump().items():
|
||||
for field, value in payload.items():
|
||||
setattr(db_edge, field, value)
|
||||
else:
|
||||
db.add(Edge(**edge_data.model_dump()))
|
||||
db.add(Edge(**payload))
|
||||
|
||||
# Upsert viewport
|
||||
state = await db.get(CanvasState, 1)
|
||||
# Upsert viewport + custom style
|
||||
state = await db.get(CanvasState, design_id)
|
||||
if state:
|
||||
state.viewport = body.viewport
|
||||
state.custom_style = body.custom_style
|
||||
state.saved_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
db.add(CanvasState(id=1, viewport=body.viewport))
|
||||
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
||||
|
||||
await db.commit()
|
||||
return {"saved": True}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
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, 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(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[DesignResponse]:
|
||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
||||
# 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)
|
||||
async def create_design(
|
||||
body: DesignCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
# Create empty canvas state for the new design
|
||||
db.add(CanvasState(design_id=design.id))
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.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,
|
||||
body: DesignUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DesignResponse:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
if body.name is not None:
|
||||
design.name = body.name
|
||||
if body.icon is not None:
|
||||
design.icon = body.icon
|
||||
await db.commit()
|
||||
await db.refresh(design)
|
||||
return DesignResponse.model_validate(design)
|
||||
|
||||
|
||||
@router.delete("/{design_id}", status_code=204)
|
||||
async def delete_design(
|
||||
design_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> None:
|
||||
design = await db.get(Design, design_id)
|
||||
if not design:
|
||||
raise HTTPException(404, "Design not found")
|
||||
# Count remaining designs — prevent deleting the last one
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, 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)
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
for n in nodes:
|
||||
await db.delete(n)
|
||||
await db.delete(design)
|
||||
await db.commit()
|
||||
@@ -4,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,22 +2,46 @@ import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
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),
|
||||
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CanvasStateResponse:
|
||||
"""Read-only public canvas endpoint.
|
||||
@@ -30,12 +54,22 @@ async def liveview_canvas(
|
||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
||||
|
||||
nodes = (await db.execute(select(Node))).scalars().all()
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
if design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
design_id = first.id if first else None
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = 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)
|
||||
+770
-43
@@ -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,18 +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 Node, PendingDevice, 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.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
|
||||
@@ -34,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()
|
||||
@@ -52,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
|
||||
|
||||
|
||||
@@ -83,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)
|
||||
@@ -98,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)
|
||||
@@ -115,35 +469,131 @@ async def bulk_approve_devices(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# 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_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.ip,
|
||||
type=device.suggested_type or "generic",
|
||||
ip=device.ip,
|
||||
hostname=device.hostname,
|
||||
status="unknown",
|
||||
services=device.services or [],
|
||||
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 approved_devices:
|
||||
all_edges.extend(
|
||||
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return {
|
||||
"approved": len(node_ids),
|
||||
"node_ids": node_ids,
|
||||
"device_ids": approved_device_ids,
|
||||
"edges_created": len(all_edges),
|
||||
"edges": all_edges,
|
||||
"skipped": len(payload.device_ids) - len(node_ids),
|
||||
"skipped_devices": skipped_devices,
|
||||
}
|
||||
|
||||
|
||||
@@ -154,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()
|
||||
@@ -166,6 +616,41 @@ async def bulk_hide_devices(
|
||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/restore", response_model=dict)
|
||||
async def restore_device(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is not hidden")
|
||||
device.status = "pending"
|
||||
await db.commit()
|
||||
return {"restored": True, "device_id": device_id}
|
||||
|
||||
|
||||
@router.post("/pending/bulk-restore", response_model=dict)
|
||||
async def bulk_restore_devices(
|
||||
payload: BulkActionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
for device in devices:
|
||||
device.status = "pending"
|
||||
await db.commit()
|
||||
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||
async def approve_device(
|
||||
device_id: str,
|
||||
@@ -173,32 +658,256 @@ async def approve_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
# Determine target design
|
||||
node_design_id = node_data.design_id
|
||||
if node_design_id is None:
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(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"
|
||||
# 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=node_data.status,
|
||||
services=node_data.services or [],
|
||||
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, node_design_id)
|
||||
|
||||
await db.commit()
|
||||
return {"approved": True, "node_id": node_id}
|
||||
return {
|
||||
"approved": True,
|
||||
"node_id": node_id,
|
||||
"edges_created": len(edges),
|
||||
"edges": edges,
|
||||
}
|
||||
|
||||
|
||||
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
|
||||
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
|
||||
|
||||
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(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 *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(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 = {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:
|
||||
return []
|
||||
|
||||
# Pre-fetch existing edges between these node ids so we don't create dups
|
||||
# if the user re-approves a device or had drawn the link manually.
|
||||
candidate_node_ids = [n.id for n in by_ieee.values()]
|
||||
existing_q = await db.execute(
|
||||
select(Edge).where(
|
||||
Edge.source.in_(candidate_node_ids),
|
||||
Edge.target.in_(candidate_node_ids),
|
||||
)
|
||||
)
|
||||
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
|
||||
|
||||
created: list[dict[str, str]] = []
|
||||
for link in links:
|
||||
other_ieee = (
|
||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||
)
|
||||
other_node = by_ieee.get(other_ieee)
|
||||
if other_node is None:
|
||||
continue
|
||||
if link.source_ieee == ieee:
|
||||
src_id, tgt_id = self_node.id, other_node.id
|
||||
else:
|
||||
src_id, tgt_id = other_node.id, self_node.id
|
||||
# Skip if either direction already exists 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:
|
||||
continue
|
||||
# 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=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))
|
||||
# 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
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/hide")
|
||||
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"
|
||||
@@ -210,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)
|
||||
@@ -226,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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _check_key(x_api_key: str | None) -> None:
|
||||
if not settings.homepage_api_key:
|
||||
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
||||
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary(
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict[str, object]:
|
||||
"""Read-only stats payload for the gethomepage `customapi` widget.
|
||||
|
||||
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
||||
value in the `X-API-Key` header.
|
||||
"""
|
||||
_check_key(x_api_key)
|
||||
|
||||
# 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(InventoryDevice.status_live, func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.group_by(InventoryDevice.status_live)
|
||||
)
|
||||
).all()
|
||||
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(InventoryDevice)
|
||||
.where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
zigbee = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.where(InventoryDevice.ieee_address.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
last_scan_at = (
|
||||
await db.execute(select(func.max(ScanRun.finished_at)))
|
||||
).scalar_one()
|
||||
|
||||
return {
|
||||
"nodes": sum(counts.values()),
|
||||
"online": counts.get("online", 0),
|
||||
"offline": counts.get("offline", 0),
|
||||
"unknown": counts.get("unknown", 0),
|
||||
# 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",
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
"""FastAPI router for Zigbee2MQTT import."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||
from sqlalchemy import delete as sa_delete
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.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 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,
|
||||
merge_zigbee_properties,
|
||||
test_mqtt_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=ZigbeeImportResponse)
|
||||
async def import_zigbee_network(
|
||||
payload: ZigbeeImportRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportResponse:
|
||||
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||
|
||||
Connects to the specified MQTT broker, publishes a networkmap request to
|
||||
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
||||
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
||||
coordinator → router → end-device hierarchy.
|
||||
"""
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
base_topic=payload.base_topic,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
except ConnectionError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
except TimeoutError as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
logger.exception("Unexpected error during Zigbee import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
||||
|
||||
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
||||
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
||||
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
async def import_zigbee_to_pending(
|
||||
payload: ZigbeeImportRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Queue a Zigbee2MQTT pending import as a background scan run.
|
||||
|
||||
Returns the ScanRun row immediately so the UI can close the import
|
||||
modal and surface progress under Scan History (kind=zigbee). The
|
||||
actual MQTT fetch + pending upsert happens in the background.
|
||||
"""
|
||||
run = ScanRun(
|
||||
status="running",
|
||||
kind="zigbee",
|
||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
||||
)
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
||||
return run
|
||||
|
||||
|
||||
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:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
base_topic=payload.base_topic,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "done"
|
||||
run.devices_found = result.device_count
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.exception("Zigbee import %s failed", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "error"
|
||||
run.error = str(exc)[:500]
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _persist_pending_import(
|
||||
db: AsyncSession,
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZigbeeImportPendingResponse:
|
||||
"""Upsert nodes/edges into 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.
|
||||
"""
|
||||
# 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
|
||||
pending_updated = 0
|
||||
|
||||
for n in nodes_raw:
|
||||
ieee = n.get("ieee_address")
|
||||
if not ieee:
|
||||
continue
|
||||
props = build_zigbee_properties(
|
||||
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||
)
|
||||
|
||||
# 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="zigbee",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||
pending.model = n.get("model") or pending.model
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
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(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for e in edges_raw:
|
||||
src = e.get("source")
|
||||
tgt = e.get("target")
|
||||
if not src or not tgt or (src, tgt) in seen:
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
links_recorded += 1
|
||||
|
||||
await db.commit()
|
||||
|
||||
return ZigbeeImportPendingResponse(
|
||||
pending_created=pending_created,
|
||||
pending_updated=pending_updated,
|
||||
coordinator=coordinator_out,
|
||||
coordinator_already_existed=coordinator_existed,
|
||||
links_recorded=links_recorded,
|
||||
device_count=len(nodes_raw),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
||||
async def test_zigbee_connection(
|
||||
payload: ZigbeeTestConnectionRequest,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeTestConnectionResponse:
|
||||
"""Quick MQTT ping to validate broker connection before importing."""
|
||||
try:
|
||||
await test_mqtt_connection(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
mqtt_port=payload.mqtt_port,
|
||||
username=payload.mqtt_username,
|
||||
password=payload.mqtt_password,
|
||||
tls=payload.mqtt_tls,
|
||||
tls_insecure=payload.mqtt_tls_insecure,
|
||||
)
|
||||
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
except (ConnectionError, TimeoutError) as exc:
|
||||
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
||||
except Exception:
|
||||
logger.exception("Unexpected error during connection test")
|
||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
||||
|
||||
|
||||
@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()
|
||||
+158
-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.
|
||||
@@ -61,9 +121,60 @@ class Settings(BaseSettings):
|
||||
# Leave unset (or empty) to keep the feature disabled (default).
|
||||
liveview_key: str | None = None
|
||||
|
||||
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
||||
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
||||
# Leave empty to keep the feature disabled (default).
|
||||
homepage_api_key: str = ""
|
||||
|
||||
# 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:
|
||||
@@ -72,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
|
||||
|
||||
@@ -81,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,34 +1,212 @@
|
||||
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
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
import bcrypt
|
||||
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
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
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:
|
||||
return False
|
||||
try:
|
||||
return bool(pwd_context.verify(plain, hashed))
|
||||
except ValueError:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return str(pwd_context.hash(password))
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def create_access_token(subject: str) -> str:
|
||||
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"}
|
||||
]
|
||||
|
||||
+542
-6
@@ -1,17 +1,37 @@
|
||||
import json as _json
|
||||
import logging
|
||||
import shutil
|
||||
import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import APP_VERSION, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
||||
"""Run an idempotent migration statement, logging any error.
|
||||
|
||||
Distinguishes 'already applied' errors (debug) from genuine failures
|
||||
(warning) so silent corruption is avoided. Used for new in-commit
|
||||
migrations; existing legacy ALTERs above remain wrapped in suppress.
|
||||
"""
|
||||
try:
|
||||
await conn.exec_driver_sql(sql)
|
||||
except OperationalError as exc:
|
||||
msg = str(exc).lower()
|
||||
if "duplicate column" in msg or "already exists" in msg:
|
||||
logger.debug("Migration %s skipped (already applied): %s", label, exc)
|
||||
else:
|
||||
logger.warning("Migration %s failed: %s", label, exc)
|
||||
|
||||
# Ensure the data directory exists before SQLite tries to open the file
|
||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -41,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):
|
||||
@@ -62,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):
|
||||
@@ -72,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):
|
||||
@@ -79,7 +167,200 @@ 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) ---
|
||||
zigbee_migrations: list[tuple[str, str]] = [
|
||||
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
|
||||
(
|
||||
"nodes.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
||||
),
|
||||
("device_inventory.ieee_address", "ALTER TABLE device_inventory ADD COLUMN ieee_address TEXT"),
|
||||
(
|
||||
"device_inventory.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(ieee_address)",
|
||||
),
|
||||
("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 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(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 device_inventory: dropping NOT NULL on ip column")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE device_inventory_new ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"ip VARCHAR,"
|
||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||
"suggested_type VARCHAR,"
|
||||
"status VARCHAR,"
|
||||
"discovery_source VARCHAR,"
|
||||
"ieee_address VARCHAR,"
|
||||
"friendly_name VARCHAR,"
|
||||
"device_subtype VARCHAR,"
|
||||
"model VARCHAR,"
|
||||
"vendor VARCHAR,"
|
||||
"lqi INTEGER,"
|
||||
"properties JSON,"
|
||||
"discovered_at DATETIME"
|
||||
")"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"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 device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE device_inventory")
|
||||
await conn.exec_driver_sql(
|
||||
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"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("device_inventory ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
await _try_migrate(
|
||||
conn,
|
||||
"CREATE TABLE IF NOT EXISTS designs ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"name VARCHAR NOT NULL,"
|
||||
"design_type VARCHAR NOT NULL DEFAULT 'network',"
|
||||
"created_at DATETIME,"
|
||||
"updated_at DATETIME"
|
||||
")",
|
||||
label="designs.table",
|
||||
)
|
||||
# Add user-chosen icon to designs (idempotent), then backfill existing rows
|
||||
# so legacy designs keep a sensible icon based on their original type.
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
|
||||
)
|
||||
# Seed default Network Topology design if designs table is empty
|
||||
_default_design_id = str(_uuid_mod.uuid4())
|
||||
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
|
||||
count_row = row.fetchone()
|
||||
count = count_row[0] if count_row else 0
|
||||
if count == 0:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
||||
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
|
||||
(_default_design_id,),
|
||||
)
|
||||
else:
|
||||
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
|
||||
default = row2.fetchone()
|
||||
_default_design_id = default[0] if default else _default_design_id
|
||||
|
||||
# Add design_id to nodes
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="nodes.design_id",
|
||||
)
|
||||
# Assign existing nodes to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Add design_id to edges
|
||||
await _try_migrate(
|
||||
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
||||
label="edges.design_id",
|
||||
)
|
||||
# Assign existing edges to default design
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
||||
)
|
||||
|
||||
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
|
||||
try:
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
|
||||
cols = info.fetchall()
|
||||
has_design_id = any(c[1] == "design_id" for c in cols)
|
||||
if not has_design_id:
|
||||
logger.info("Migrating canvas_state: switching to design_id primary key")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE canvas_state_new ("
|
||||
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
|
||||
"viewport JSON,"
|
||||
"custom_style JSON,"
|
||||
"saved_at DATETIME"
|
||||
")"
|
||||
)
|
||||
# Copy existing row(s), mapping id=1 to default design_id
|
||||
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
|
||||
for old in old_rows.fetchall():
|
||||
cs_id, viewport, custom_style, saved_at = old
|
||||
target_design = _default_design_id
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(target_design, viewport, custom_style, saved_at),
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE canvas_state")
|
||||
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
# 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):
|
||||
@@ -90,8 +371,8 @@ async def init_db() -> None:
|
||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
||||
"FROM nodes WHERE properties IS NULL"
|
||||
)
|
||||
for row in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
||||
for r in rows.fetchall():
|
||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
||||
props = []
|
||||
visible = bool(show_hardware)
|
||||
if cpu_model:
|
||||
@@ -102,17 +383,272 @@ async def init_db() -> None:
|
||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
||||
if disk_gb is not None:
|
||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
||||
import json as _json
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
||||
(_json.dumps(props), node_id),
|
||||
)
|
||||
# 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]:
|
||||
|
||||
+214
-21
@@ -16,38 +16,52 @@ def _uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Design(Base):
|
||||
__tablename__ = "designs"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
||||
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""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)
|
||||
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)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
# 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)
|
||||
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")
|
||||
@@ -60,13 +74,18 @@ class Edge(Base):
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||
label: Mapped[str | None] = mapped_column(String)
|
||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||
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)
|
||||
@@ -76,23 +95,196 @@ class Edge(Base):
|
||||
class CanvasState(Base):
|
||||
__tablename__ = "canvas_state"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
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)
|
||||
ip: Mapped[str] = mapped_column(String, nullable=False)
|
||||
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)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
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
|
||||
)
|
||||
|
||||
# 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 InventoryDevice. On approval, the matching Edge is auto-created when both
|
||||
endpoints exist as canvas Nodes.
|
||||
"""
|
||||
|
||||
__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)
|
||||
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
@@ -101,6 +293,7 @@ class ScanRun(Base):
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
status: Mapped[str] = mapped_column(String, default="running")
|
||||
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
|
||||
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+35
-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, edges, liveview, nodes, scan, status
|
||||
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,22 +56,38 @@ 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"])
|
||||
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,14 +76,27 @@ 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] = []
|
||||
edges: list[EdgeSave] = []
|
||||
viewport: dict[str, Any] = {}
|
||||
custom_style: dict[str, Any] | None = None
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class CanvasStateResponse(BaseModel):
|
||||
nodes: list[NodeResponse]
|
||||
edges: list[EdgeResponse]
|
||||
viewport: dict[str, Any]
|
||||
custom_style: dict[str, Any] | None = None
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
|
||||
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"
|
||||
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
|
||||
design_type: str
|
||||
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,9 +28,14 @@ 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):
|
||||
pass
|
||||
design_id: str | None = None
|
||||
|
||||
|
||||
class EdgeUpdate(BaseModel):
|
||||
@@ -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):
|
||||
pass
|
||||
# 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,15 +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]
|
||||
+146
-3
@@ -1,12 +1,12 @@
|
||||
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
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
hostname: str | None
|
||||
os: str | None
|
||||
@@ -14,14 +14,157 @@ 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
|
||||
kind: str = "ip"
|
||||
ranges: list[str]
|
||||
devices_found: int
|
||||
started_at: datetime
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class ZigbeeImportRequest(BaseModel):
|
||||
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
||||
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
||||
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
||||
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
||||
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
||||
mqtt_tls_insecure: bool = Field(
|
||||
False, description="Skip TLS certificate verification (self-signed only)"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZigbeeTestConnectionRequest(BaseModel):
|
||||
mqtt_host: str
|
||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||
mqtt_username: str | None = None
|
||||
mqtt_password: str | None = None
|
||||
mqtt_tls: bool = False
|
||||
mqtt_tls_insecure: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
|
||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
||||
return self
|
||||
|
||||
|
||||
class ZigbeeDeviceData(BaseModel):
|
||||
ieee_address: str
|
||||
friendly_name: str
|
||||
device_type: str # Coordinator, Router, EndDevice
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
description: str | None = None
|
||||
lqi: int | None = None
|
||||
last_seen: str | None = None
|
||||
|
||||
|
||||
class ZigbeeNodeOut(BaseModel):
|
||||
"""A homelable-ready node representation of a Zigbee device."""
|
||||
|
||||
id: str
|
||||
label: str
|
||||
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
|
||||
ieee_address: str
|
||||
friendly_name: str
|
||||
device_type: str
|
||||
model: str | None = None
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class ZigbeeEdgeOut(BaseModel):
|
||||
source: str
|
||||
target: str
|
||||
|
||||
|
||||
class ZigbeeImportResponse(BaseModel):
|
||||
nodes: list[ZigbeeNodeOut]
|
||||
edges: list[ZigbeeEdgeOut]
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ZigbeeCoordinatorOut(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
ieee_address: str
|
||||
|
||||
|
||||
class ZigbeeImportPendingResponse(BaseModel):
|
||||
"""Result of importing a Z2M network into the pending section."""
|
||||
|
||||
pending_created: int
|
||||
pending_updated: int
|
||||
coordinator: ZigbeeCoordinatorOut | None = None
|
||||
coordinator_already_existed: bool = False
|
||||
links_recorded: int
|
||||
device_count: int
|
||||
|
||||
|
||||
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,689 @@
|
||||
"""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 datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, or_, select, text
|
||||
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
|
||||
|
||||
|
||||
# Legacy `nodes` columns SQLite stores as DATETIME. Read through raw SQL they come
|
||||
# back as text, and writing text into a DateTime column raises
|
||||
# ``TypeError: SQLite DateTime type only accepts Python datetime and date objects``.
|
||||
_LEGACY_DATETIME_COLUMNS = ("last_seen", "last_scan")
|
||||
|
||||
|
||||
def _decode_dt(value: Any) -> datetime | None:
|
||||
"""Raw SQL hands back DATETIME columns as text; the ORM would have parsed them.
|
||||
|
||||
An unparseable stamp becomes ``None`` rather than an error: `last_seen` and
|
||||
`last_scan` are observations the status checker refreshes within a minute,
|
||||
and losing one must not cost a node its inventory row.
|
||||
"""
|
||||
if value is None or isinstance(value, datetime):
|
||||
return value
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(value.strip())
|
||||
except ValueError:
|
||||
logger.debug("Ignoring an unparseable legacy timestamp: %r", value)
|
||||
return None
|
||||
|
||||
|
||||
async def _row_is_missing_facts(db: AsyncSession, device_id: str, facts: Mapping[str, Any]) -> bool:
|
||||
"""Does the linked row lack a fact the node's legacy columns still hold?
|
||||
|
||||
True only where the node has something to give and the row has nothing in
|
||||
that field, so a node whose facts already made it across is left alone and
|
||||
the backfill stays a no-op on later boots.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None:
|
||||
return True # The row is gone; the node needs a new one.
|
||||
for field in (*DEVICE_SCALARS, "label", "type", "ieee_address"):
|
||||
if not _blank(facts.get(field)) and _blank(getattr(device, field, None)):
|
||||
return True
|
||||
return any(
|
||||
facts.get(field) and not getattr(device, field, None)
|
||||
for field in ("services", "properties")
|
||||
)
|
||||
|
||||
|
||||
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))
|
||||
# Linked nodes are read too, not just unlinked ones. A canvas saved while an
|
||||
# earlier migration was stuck minted a *blank* inventory row from a UI that
|
||||
# had no facts to show, and linked the node to it. Skipping those would count
|
||||
# them as migrated and drop the columns that still hold their only copy of
|
||||
# the ip, services and notes. A linked node is filled, never overwritten.
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
f"SELECT id, label, type, design_id, device_id, {placeholders} FROM nodes "
|
||||
f"WHERE 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 []
|
||||
for field in _LEGACY_DATETIME_COLUMNS:
|
||||
if field in facts:
|
||||
facts[field] = _decode_dt(facts[field])
|
||||
facts["label"] = row["label"]
|
||||
facts["type"] = row["type"]
|
||||
|
||||
# One savepoint per node: a row the merge cannot write 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. The catch is deliberately broad — a node that cannot be read
|
||||
# or written for *any* reason must not cost the others their link.
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
node = await db.get(Node, row["id"])
|
||||
if node is None: # pragma: no cover - the row was just read
|
||||
continue
|
||||
if node.device_id and not await _row_is_missing_facts(db, node.device_id, facts):
|
||||
continue # Already migrated, and its row holds the facts.
|
||||
existing = await find_device_for(
|
||||
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
|
||||
)
|
||||
# A node that is already linked only has its gaps filled: whatever
|
||||
# is on the row was written after the migration and is newer.
|
||||
device = await link_facts(
|
||||
db, node, facts, overwrite_scalars=node.device_id is None
|
||||
)
|
||||
if device is None:
|
||||
continue
|
||||
await db.flush()
|
||||
except Exception 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
|
||||
|
||||
@@ -24,6 +24,11 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
host = target or raw_ip
|
||||
if not host:
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
|
||||
# against arg-injection if a malicious admin sets target like "-O".
|
||||
if host.startswith("-"):
|
||||
logger.warning("Rejecting check target that starts with '-': %r", host)
|
||||
return {"status": "unknown", "response_time_ms": None}
|
||||
|
||||
start = time.monotonic()
|
||||
try:
|
||||
@@ -59,11 +64,35 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
||||
return {"status": "offline", "response_time_ms": None}
|
||||
|
||||
|
||||
def _is_ipv6(host: str) -> bool:
|
||||
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
async def _ping(host: str) -> bool:
|
||||
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
||||
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
||||
#
|
||||
# -W flag units differ by OS:
|
||||
# Linux: seconds (-W 2 = 2s)
|
||||
# macOS: milliseconds (-W 2000 = 2s)
|
||||
# Windows: -w in ms (-w 2000 = 2s)
|
||||
#
|
||||
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
||||
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
||||
ipv6 = _is_ipv6(host)
|
||||
if sys.platform == "win32":
|
||||
args = ["ping", "-n", "1", "-w", "1000", host]
|
||||
family = ["-6"] if ipv6 else ["-4"]
|
||||
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
||||
elif sys.platform == "darwin":
|
||||
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
||||
else:
|
||||
args = ["ping", "-c", "1", "-W", "1", host]
|
||||
family = ["-6"] if ipv6 else []
|
||||
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
@@ -89,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 []
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import aiomqtt
|
||||
except ImportError: # pragma: no cover
|
||||
aiomqtt = None # type: ignore[assignment]
|
||||
|
||||
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
||||
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
||||
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
|
||||
|
||||
def build_zigbee_properties(
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
lqi: int | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
|
||||
|
||||
Only includes a row when the value is non-empty. Shape matches the
|
||||
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
||||
|
||||
New props default to ``visible=False`` — users opt in to showing them on
|
||||
the canvas card from the right panel.
|
||||
"""
|
||||
props: list[dict[str, Any]] = []
|
||||
if ieee:
|
||||
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
||||
if vendor:
|
||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||
if model:
|
||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||
if lqi is not None:
|
||||
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
||||
return props
|
||||
|
||||
|
||||
def merge_zigbee_properties(
|
||||
existing: list[dict[str, Any]] | None,
|
||||
new_props: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Merge fresh zigbee props into an existing property list.
|
||||
|
||||
For keys already present: update ``value`` but preserve the user's
|
||||
``visible`` choice. New keys are appended with whatever visibility the
|
||||
caller gave them (hidden by default per ``build_zigbee_properties``).
|
||||
Non-zigbee custom properties are preserved untouched.
|
||||
"""
|
||||
out = [dict(p) for p in (existing or [])]
|
||||
by_key = {p.get("key"): p for p in out}
|
||||
for np in new_props:
|
||||
key = np.get("key")
|
||||
if key in by_key:
|
||||
by_key[key]["value"] = np.get("value")
|
||||
else:
|
||||
out.append(dict(np))
|
||||
return out
|
||||
|
||||
|
||||
def _z2m_type_to_homelable(device_type: str) -> str:
|
||||
"""Map a Z2M device type string to a homelable node type."""
|
||||
mapping = {
|
||||
"Coordinator": "zigbee_coordinator",
|
||||
"Router": "zigbee_router",
|
||||
"EndDevice": "zigbee_enddevice",
|
||||
}
|
||||
return mapping.get(device_type, "zigbee_enddevice")
|
||||
|
||||
|
||||
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
|
||||
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
|
||||
if not ieee:
|
||||
return None
|
||||
device_type: str = raw.get("type") or "EndDevice"
|
||||
friendly_name: str = (
|
||||
raw.get("friendlyName") or raw.get("friendly_name") or ieee
|
||||
)
|
||||
definition: dict[str, Any] = raw.get("definition") or {}
|
||||
model: str | None = (
|
||||
raw.get("modelID")
|
||||
or raw.get("model")
|
||||
or definition.get("model")
|
||||
or None
|
||||
)
|
||||
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": friendly_name,
|
||||
"type": _z2m_type_to_homelable(device_type),
|
||||
"ieee_address": ieee,
|
||||
"friendly_name": friendly_name,
|
||||
"device_type": device_type,
|
||||
"model": model,
|
||||
"vendor": vendor,
|
||||
"lqi": None,
|
||||
"parent_id": None,
|
||||
}
|
||||
|
||||
|
||||
def parse_networkmap(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
|
||||
|
||||
Z2M raw response shape::
|
||||
|
||||
{
|
||||
"data": {
|
||||
"type": "raw",
|
||||
"routes": false,
|
||||
"value": {
|
||||
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
|
||||
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
|
||||
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
|
||||
"lqi": 200, "depth": 1}]
|
||||
}
|
||||
},
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
Older or alternate shapes may put nodes/links directly under ``data``.
|
||||
Both are accepted.
|
||||
"""
|
||||
data: dict[str, Any] = payload.get("data") or {}
|
||||
value = data.get("value")
|
||||
container: dict[str, Any] = value if isinstance(value, dict) else data
|
||||
|
||||
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
|
||||
raw_links: list[dict[str, Any]] = container.get("links") or []
|
||||
|
||||
if not isinstance(raw_nodes, list):
|
||||
raise ValueError("Malformed networkmap: 'nodes' is not a list")
|
||||
if not isinstance(raw_links, list):
|
||||
raise ValueError("Malformed networkmap: 'links' is not a list")
|
||||
|
||||
nodes_list: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
coordinator_id: str | None = None
|
||||
|
||||
for entry in raw_nodes:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
node = _node_from_z2m(entry)
|
||||
if node is None or node["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(node["id"])
|
||||
nodes_list.append(node)
|
||||
if node["device_type"] == "Coordinator":
|
||||
coordinator_id = node["id"]
|
||||
|
||||
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
|
||||
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
|
||||
# resolve which router an end device hangs off; do NOT emit edges directly
|
||||
# from links. The final edge set is the strict parent→child tree built
|
||||
# from parent_id below — that avoids duplicate edges and keeps the visual
|
||||
# flow consistent (parent bottom → child top).
|
||||
raw_edges: list[dict[str, Any]] = []
|
||||
lqi_by_id: dict[str, int] = {}
|
||||
|
||||
for link in raw_links:
|
||||
if not isinstance(link, dict):
|
||||
continue
|
||||
src_obj = link.get("source") or {}
|
||||
tgt_obj = link.get("target") or {}
|
||||
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
|
||||
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
|
||||
if not src or not tgt:
|
||||
continue
|
||||
if src not in seen_ids or tgt not in seen_ids:
|
||||
continue
|
||||
raw_edges.append({"source": src, "target": tgt})
|
||||
lqi = link.get("lqi") or link.get("linkquality")
|
||||
if isinstance(lqi, int) and tgt not in lqi_by_id:
|
||||
lqi_by_id[tgt] = lqi
|
||||
|
||||
for node in nodes_list:
|
||||
if node["id"] in lqi_by_id:
|
||||
node["lqi"] = lqi_by_id[node["id"]]
|
||||
|
||||
# Build parent_id hierarchy: coordinator → routers → end devices
|
||||
if coordinator_id:
|
||||
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
||||
for node in nodes_list:
|
||||
if node["device_type"] == "Router":
|
||||
node["parent_id"] = coordinator_id
|
||||
elif node["device_type"] == "EndDevice":
|
||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||
node["parent_id"] = parent or coordinator_id
|
||||
|
||||
# Final edges = strict parent → child tree (one edge per non-coordinator)
|
||||
edges_list: list[dict[str, Any]] = [
|
||||
{"source": node["parent_id"], "target": node["id"]}
|
||||
for node in nodes_list
|
||||
if node.get("parent_id")
|
||||
]
|
||||
|
||||
return nodes_list, edges_list
|
||||
|
||||
|
||||
def _find_parent_router(
|
||||
device_id: str,
|
||||
router_ids: set[str],
|
||||
edges: list[dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Return the first router that has a direct edge to device_id."""
|
||||
for edge in edges:
|
||||
src: str = edge["source"]
|
||||
tgt: str = edge["target"]
|
||||
if tgt == device_id and src in router_ids:
|
||||
return src
|
||||
if src == device_id and tgt in router_ids:
|
||||
return tgt
|
||||
return None
|
||||
|
||||
|
||||
async def fetch_networkmap(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
base_topic: str,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the broker does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is malformed.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError(
|
||||
"aiomqtt is required for Zigbee import. "
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||
|
||||
response_payload: dict[str, Any] = {}
|
||||
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
) as client:
|
||||
await client.subscribe(response_topic)
|
||||
# Give the broker a brief window to register the subscription
|
||||
# before we publish the request. Without this, brokers that
|
||||
# race SUBACK with our PUBLISH may deliver the response before
|
||||
# the subscription is active and we'd hang until timeout.
|
||||
await asyncio.sleep(0.1)
|
||||
await client.publish(
|
||||
request_topic,
|
||||
json.dumps({"type": "raw", "routes": False}),
|
||||
)
|
||||
|
||||
async def _wait_for_response() -> None:
|
||||
async for message in client.messages:
|
||||
if str(message.topic) != response_topic:
|
||||
continue
|
||||
raw = message.payload
|
||||
try:
|
||||
payload_str = (
|
||||
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
||||
)
|
||||
response_payload.update(json.loads(payload_str))
|
||||
except (json.JSONDecodeError, TypeError) as exc:
|
||||
raise ValueError(
|
||||
f"Malformed networkmap response: {exc}"
|
||||
) from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty networkmap response received")
|
||||
|
||||
return parse_networkmap(response_payload)
|
||||
|
||||
|
||||
async def test_mqtt_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Attempt a quick MQTT connection to verify broker reachability.
|
||||
|
||||
Returns True on success, raises ConnectionError on failure.
|
||||
"""
|
||||
if aiomqtt is None: # pragma: no cover
|
||||
raise ImportError("aiomqtt is required")
|
||||
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
try:
|
||||
async with aiomqtt.Client(
|
||||
hostname=mqtt_host,
|
||||
port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
timeout=_CONNECTION_TIMEOUT,
|
||||
tls_context=tls_context,
|
||||
):
|
||||
return True
|
||||
except aiomqtt.MqttError as exc:
|
||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError("Connection to broker timed out") from exc
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
||||
|
||||
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
||||
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
||||
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.services.mqtt_common import request_response, test_connection
|
||||
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
||||
merge_zwave_properties = merge_zigbee_properties
|
||||
|
||||
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
||||
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
||||
|
||||
|
||||
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
||||
"""Map a Z-Wave node's role flags to a homelable node type.
|
||||
|
||||
Controller → coordinator. Mains-powered / routing nodes → router.
|
||||
Everything else (battery sensors, etc.) → end device.
|
||||
"""
|
||||
if raw.get("isControllerNode"):
|
||||
return "zwave_coordinator"
|
||||
if raw.get("isRouting"):
|
||||
return "zwave_router"
|
||||
return "zwave_enddevice"
|
||||
|
||||
|
||||
def _role_label(node_type: str) -> str:
|
||||
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
||||
return {
|
||||
"zwave_coordinator": "Controller",
|
||||
"zwave_router": "Router",
|
||||
"zwave_enddevice": "EndDevice",
|
||||
}.get(node_type, "EndDevice")
|
||||
|
||||
|
||||
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
||||
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
||||
node_id = raw.get("id")
|
||||
if node_id is None:
|
||||
return None
|
||||
ieee = f"zwave-{home_id}-{node_id}"
|
||||
node_type = _zwave_type_to_homelable(raw)
|
||||
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
||||
model = raw.get("productLabel") or raw.get("productDescription") or None
|
||||
vendor = raw.get("manufacturer") or None
|
||||
return {
|
||||
"id": ieee,
|
||||
"label": name,
|
||||
"type": node_type,
|
||||
"ieee_address": ieee,
|
||||
"friendly_name": name,
|
||||
"device_type": _role_label(node_type),
|
||||
"node_id": node_id,
|
||||
"model": model,
|
||||
"vendor": vendor,
|
||||
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
||||
"parent_id": None,
|
||||
"neighbors": raw.get("neighbors") or [],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
||||
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
||||
controller_home = None
|
||||
for entry in raw_nodes:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
home = entry.get("homeId")
|
||||
if home is None:
|
||||
continue
|
||||
if entry.get("isControllerNode"):
|
||||
return str(home)
|
||||
if controller_home is None:
|
||||
controller_home = str(home)
|
||||
return controller_home or "0"
|
||||
|
||||
|
||||
def parse_zwave_nodes(
|
||||
payload: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
||||
|
||||
Expected shape::
|
||||
|
||||
{"success": true, "result": [ {<node>}, ... ]}
|
||||
|
||||
Edges are a strict coordinator → router → end-device tree, derived from
|
||||
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
||||
"""
|
||||
if payload.get("success") is False:
|
||||
raise ValueError("Z-Wave gateway reported failure")
|
||||
|
||||
result = payload.get("result")
|
||||
if result is None:
|
||||
result = []
|
||||
if not isinstance(result, list):
|
||||
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
||||
|
||||
home_id = _resolve_home_id(result)
|
||||
|
||||
nodes_list: list[dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
coordinator_id: str | None = None
|
||||
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
||||
id_by_node_id: dict[Any, str] = {}
|
||||
|
||||
for entry in result:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
node = _node_from_zwave(entry, home_id)
|
||||
if node is None or node["id"] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(node["id"])
|
||||
id_by_node_id[node["node_id"]] = node["id"]
|
||||
nodes_list.append(node)
|
||||
if node["type"] == "zwave_coordinator":
|
||||
coordinator_id = node["id"]
|
||||
|
||||
# Translate neighbor lists into candidate edges (only between known nodes).
|
||||
raw_edges: list[dict[str, Any]] = []
|
||||
for node in nodes_list:
|
||||
src = node["id"]
|
||||
for neighbor in node.get("neighbors") or []:
|
||||
tgt = id_by_node_id.get(neighbor)
|
||||
if tgt and tgt != src:
|
||||
raw_edges.append({"source": src, "target": tgt})
|
||||
|
||||
# Build parent_id hierarchy: coordinator → routers → end devices.
|
||||
if coordinator_id:
|
||||
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
||||
for node in nodes_list:
|
||||
if node["type"] == "zwave_router":
|
||||
node["parent_id"] = coordinator_id
|
||||
elif node["type"] == "zwave_enddevice":
|
||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
||||
node["parent_id"] = parent or coordinator_id
|
||||
|
||||
# Final edges = strict parent → child tree (one edge per non-coordinator).
|
||||
edges_list: list[dict[str, Any]] = [
|
||||
{"source": node["parent_id"], "target": node["id"]}
|
||||
for node in nodes_list
|
||||
if node.get("parent_id")
|
||||
]
|
||||
|
||||
# Drop transient helper keys before returning.
|
||||
for node in nodes_list:
|
||||
node.pop("neighbors", None)
|
||||
node.pop("node_id", None)
|
||||
|
||||
return nodes_list, edges_list
|
||||
|
||||
|
||||
def build_zwave_properties(
|
||||
ieee: str | None,
|
||||
vendor: str | None,
|
||||
model: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
||||
|
||||
Z-Wave has no LQI, so that row is omitted. New props default to
|
||||
``visible=False`` — users opt in from the right panel.
|
||||
"""
|
||||
props: list[dict[str, Any]] = []
|
||||
if ieee:
|
||||
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
|
||||
if vendor:
|
||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
||||
if model:
|
||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
||||
return props
|
||||
|
||||
|
||||
async def fetch_zwave_network(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
prefix: str = "zwave",
|
||||
gateway_name: str = "zwavejs2mqtt",
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the gateway does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
ValueError: if the response payload is malformed.
|
||||
"""
|
||||
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
||||
|
||||
payload = await request_response(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
request_topic=request_topic,
|
||||
response_topic=response_topic,
|
||||
request_payload={"args": []},
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
|
||||
return parse_zwave_nodes(payload)
|
||||
|
||||
|
||||
async def test_zwave_connection(
|
||||
mqtt_host: str,
|
||||
mqtt_port: int,
|
||||
username: str | None = None,
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
) -> bool:
|
||||
"""Quick MQTT reachability check for the Z-Wave broker."""
|
||||
return await test_connection(
|
||||
mqtt_host=mqtt_host,
|
||||
mqtt_port=mqtt_port,
|
||||
username=username,
|
||||
password=password,
|
||||
tls=tls,
|
||||
tls_insecure=tls_insecure,
|
||||
)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -6,17 +6,19 @@ aiosqlite==0.20.0
|
||||
alembic==1.13.3
|
||||
pydantic==2.9.2
|
||||
pydantic-settings==2.5.2
|
||||
python-jose[cryptography]==3.5.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
bcrypt==4.0.1
|
||||
python-multipart==0.0.26
|
||||
PyJWT==2.13.0
|
||||
bcrypt==4.2.1
|
||||
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.131.0
|
||||
zeroconf==0.149.16
|
||||
aiomqtt==2.3.0
|
||||
|
||||
# Dev
|
||||
ruff==0.6.9
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"""Generate a bcrypt password hash for config.yml."""
|
||||
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
|
||||
import sys
|
||||
|
||||
from passlib.context import CryptContext
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
import bcrypt
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python scripts/hash_password.py <password>")
|
||||
sys.exit(1)
|
||||
|
||||
password = sys.argv[1]
|
||||
print(pwd_context.hash(password))
|
||||
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
|
||||
|
||||
@@ -5,23 +5,21 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.database import Base, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def test_credentials():
|
||||
"""Configure test auth credentials directly on settings."""
|
||||
from app.core.config import settings
|
||||
settings.auth_username = "admin"
|
||||
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
||||
settings.auth_password_hash = hash_password("admin")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -46,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)
|
||||
@@ -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
|
||||
@@ -68,3 +125,299 @@ async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
settings.auth_password_hash = original
|
||||
|
||||
|
||||
# --- JWT-level cases ---
|
||||
|
||||
async def test_expired_token_rejected(client: AsyncClient):
|
||||
"""A JWT whose `exp` is in the past must be refused."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
|
||||
from app.core.config import settings
|
||||
payload = {
|
||||
"sub": "admin",
|
||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
}
|
||||
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_malformed_token_rejected(client: AsyncClient):
|
||||
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
||||
"""A token signed with a different key must not be accepted."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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-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
|
||||
|
||||
|
||||
async def test_missing_authorization_header_rejected(client: AsyncClient):
|
||||
res = await client.get("/api/v1/nodes")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
|
||||
"""No credentials configured server-side must not authenticate an empty password."""
|
||||
from app.core.config import settings
|
||||
original_hash = settings.auth_password_hash
|
||||
settings.auth_password_hash = ""
|
||||
try:
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
|
||||
assert res.status_code == 401
|
||||
finally:
|
||||
settings.auth_password_hash = original_hash
|
||||
|
||||
|
||||
# --- Password helper ---
|
||||
|
||||
def test_verify_password_handles_empty_inputs():
|
||||
"""verify_password must be safe against empty plain / empty hash without raising."""
|
||||
from app.core.security import hash_password, verify_password
|
||||
h = hash_password("hunter2")
|
||||
assert verify_password("hunter2", h) is True
|
||||
assert verify_password("", h) is False
|
||||
assert verify_password("hunter2", "") is False
|
||||
assert verify_password("", "") is False
|
||||
|
||||
|
||||
# --- 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)
|
||||
@@ -557,3 +667,42 @@ async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: di
|
||||
edge = canvas["edges"][0]
|
||||
assert edge["label"] == "updated"
|
||||
assert edge["custom_color"] == "#ff0000"
|
||||
|
||||
|
||||
# ── custom_style ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
||||
custom_style = {
|
||||
"nodes": {
|
||||
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
||||
},
|
||||
"edges": {
|
||||
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
||||
},
|
||||
}
|
||||
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
||||
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
assert res.status_code == 200
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert canvas["custom_style"] is not None
|
||||
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
||||
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
||||
|
||||
|
||||
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/canvas", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["custom_style"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
||||
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
||||
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
||||
|
||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||
assert "proxmox" in canvas["custom_style"]["nodes"]
|
||||
assert "server" not in canvas["custom_style"]["nodes"]
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,270 @@
|
||||
import uuid
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
def node_payload(**kwargs):
|
||||
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
||||
|
||||
|
||||
def edge_payload(src, tgt, **kwargs):
|
||||
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
|
||||
|
||||
|
||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_requires_auth(client: AsyncClient):
|
||||
res = await client.get("/api/v1/designs")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
async def test_create_design_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/designs", json={"name": "X"})
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
# ── list / create ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_list_designs_empty(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/designs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
async def test_create_design_defaults(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Workshop")
|
||||
assert design["name"] == "Workshop"
|
||||
assert design["design_type"] == "network"
|
||||
assert design["icon"] == "dashboard"
|
||||
assert "id" in design and design["id"]
|
||||
|
||||
|
||||
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Net", design_type="network")
|
||||
assert design["design_type"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Power", icon="zap")
|
||||
assert design["icon"] == "zap"
|
||||
|
||||
|
||||
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="D", icon="dashboard")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["icon"] == "server"
|
||||
# Name left untouched when only icon is sent.
|
||||
assert res.json()["name"] == "D"
|
||||
|
||||
|
||||
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Old", icon="dashboard")
|
||||
res = await client.put(
|
||||
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["name"] == "New"
|
||||
assert body["icon"] == "network"
|
||||
|
||||
|
||||
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Has Canvas")
|
||||
# Loading the new design returns an (empty) canvas without falling back to another design.
|
||||
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == []
|
||||
assert body["edges"] == []
|
||||
|
||||
|
||||
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
|
||||
a = await _create(client, headers, name="First")
|
||||
b = await _create(client, headers, name="Second")
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
ids = [d["id"] for d in listed]
|
||||
assert ids == [a["id"], b["id"]]
|
||||
|
||||
|
||||
# ── 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):
|
||||
design = await _create(client, headers, name="Old Name")
|
||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["name"] == "New Name"
|
||||
|
||||
|
||||
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ── delete ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
|
||||
design = await _create(client, headers, name="Only One")
|
||||
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
|
||||
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
|
||||
await _create(client, headers, name="Keep")
|
||||
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
|
||||
keep = await _create(client, headers, name="Keep")
|
||||
victim = await _create(client, headers, name="Victim")
|
||||
|
||||
# Populate the victim design with nodes + an edge via canvas save.
|
||||
n1 = node_payload(label="A")
|
||||
n2 = node_payload(label="B")
|
||||
e1 = edge_payload(n1["id"], n2["id"])
|
||||
save = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
assert save.status_code == 200
|
||||
|
||||
# Populate the kept design too, to prove scoping.
|
||||
k1 = node_payload(label="K")
|
||||
await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
|
||||
assert res.status_code == 204
|
||||
|
||||
# Victim gone from list.
|
||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
||||
assert [d["id"] for d in listed] == [keep["id"]]
|
||||
|
||||
# Kept design's node survives untouched.
|
||||
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
|
||||
assert len(kept_canvas["nodes"]) == 1
|
||||
assert kept_canvas["nodes"][0]["label"] == "K"
|
||||
+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,982 @@
|
||||
"""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_carries_over_timestamps_stored_as_text(self, db_session):
|
||||
"""The legacy columns are read with raw SQL, so SQLite hands the DATETIME
|
||||
ones back as strings. Writing one straight into the row raises
|
||||
``TypeError: SQLite DateTime type only accepts Python datetime and date
|
||||
objects`` — which used to abort the whole backfill (issue #347)."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
node_id = await self._legacy_node(
|
||||
db_session,
|
||||
design,
|
||||
ip="10.0.0.5",
|
||||
label="Dockerhost",
|
||||
status="online",
|
||||
last_seen="2026-08-15 04:10:22.123456",
|
||||
last_scan="2026-08-15 04:00:00",
|
||||
)
|
||||
|
||||
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.last_seen == datetime(2026, 8, 15, 4, 10, 22, 123456)
|
||||
assert device.last_scan == datetime(2026, 8, 15, 4, 0, 0)
|
||||
assert device.status_live == "online"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_stamp_it_cannot_parse_costs_only_the_stamp(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", last_seen="not a date")
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = (await db_session.execute(select(InventoryDevice))).scalars().one()
|
||||
assert device.last_seen is None
|
||||
assert device.ip == "10.0.0.5"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_one_unwritable_node_does_not_cost_the_others_their_link(self, db_session, monkeypatch):
|
||||
"""Whatever a single node raises — not only a constraint violation — the
|
||||
rest of the canvas still gets linked."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.5", label="good")
|
||||
await self._legacy_node(db_session, design, ip="10.0.0.6", label="bad")
|
||||
|
||||
import app.services.inventory_sync as module
|
||||
|
||||
real = module.link_facts
|
||||
|
||||
async def link_facts(db, node, facts, **kwargs):
|
||||
if facts.get("ip") == "10.0.0.6":
|
||||
raise TypeError("SQLite DateTime type only accepts Python datetime")
|
||||
return await real(db, node, facts, **kwargs)
|
||||
|
||||
monkeypatch.setattr(module, "link_facts", link_facts)
|
||||
|
||||
stats = await module.backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
assert stats["skipped"] == 1
|
||||
devices = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert [d.ip for d in devices] == ["10.0.0.5"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fills_a_blank_row_a_stuck_canvas_save_created(self, db_session):
|
||||
"""A save made while an earlier migration was stuck linked the node to a
|
||||
row minted from a blank UI. Those facts only exist in the legacy columns,
|
||||
and the drop is about to remove them — so fill the row before it does."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-blank", ip="", label="Dockerhost", services=[]))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, label="Dockerhost", ip="192.168.0.42", hostname="clara.lan",
|
||||
notes="in the cupboard", services=[{"port": 443, "protocol": "tcp", "service_name": "https"}],
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-blank"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 1
|
||||
device = await db_session.get(InventoryDevice, "d-blank")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.42"
|
||||
assert device.hostname == "clara.lan"
|
||||
assert device.notes == "in the cupboard"
|
||||
assert [s["service_name"] for s in device.services] == ["https"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leaves_a_linked_row_that_already_has_the_facts_alone(self, db_session):
|
||||
"""The mirror of the above: a row a user has since edited is not reverted
|
||||
to what the node's abandoned columns still say."""
|
||||
await self._legacy_nodes_table(db_session)
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(
|
||||
id="d-1", ip="192.168.0.99", hostname="renamed.lan",
|
||||
label="Dockerhost", type="docker_host",
|
||||
))
|
||||
await db_session.commit()
|
||||
node_id = await self._legacy_node(
|
||||
db_session, design, ip="192.168.0.42", hostname="clara.lan", label="Dockerhost"
|
||||
)
|
||||
node = await db_session.get(Node, node_id)
|
||||
node.device_id = "d-1"
|
||||
await db_session.commit()
|
||||
|
||||
stats = await backfill_node_devices(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
assert stats["linked"] == 0
|
||||
device = await db_session.get(InventoryDevice, "d-1")
|
||||
assert device is not None
|
||||
assert device.ip == "192.168.0.99"
|
||||
assert device.hostname == "renamed.lan"
|
||||
|
||||
@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 = {
|
||||
@@ -112,6 +111,27 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, headers):
|
||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||
settings.liveview_key = "test-key"
|
||||
payload = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["viewport"].get("theme_id") == "matrix"
|
||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -124,3 +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,544 +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
|
||||
|
||||
|
||||
# --- 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
|
||||
|
||||
|
||||
# --- 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.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
|
||||
+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
|
||||
@@ -0,0 +1,108 @@
|
||||
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_homepage_key():
|
||||
original = settings.homepage_api_key
|
||||
settings.homepage_api_key = ""
|
||||
yield
|
||||
settings.homepage_api_key = original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
assert "disabled" in res.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get("/api/v1/stats/summary")
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
||||
)
|
||||
assert res.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_empty_db(client: AsyncClient) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {
|
||||
"nodes": 0,
|
||||
"online": 0,
|
||||
"offline": 0,
|
||||
"unknown": 0,
|
||||
"pending_devices": 0,
|
||||
"zigbee_devices": 0,
|
||||
"last_scan_at": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_aggregates_counts(
|
||||
client: AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
settings.homepage_api_key = "topsecret"
|
||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
||||
# 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([
|
||||
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)),
|
||||
])
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get(
|
||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["nodes"] == 6
|
||||
assert body["online"] == 4
|
||||
assert body["offline"] == 1
|
||||
assert body["unknown"] == 1
|
||||
assert body["pending_devices"] == 2
|
||||
assert body["zigbee_devices"] == 2
|
||||
# SQLite returns naive datetimes; compare prefix only.
|
||||
assert body["last_scan_at"] is not None
|
||||
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
||||
@@ -5,7 +5,13 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
||||
from app.api.routes.status import (
|
||||
_connections,
|
||||
_drop,
|
||||
broadcast_scan_update,
|
||||
broadcast_service_status,
|
||||
broadcast_status,
|
||||
)
|
||||
from app.main import app
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user