Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ab20a9b9 | ||
|
|
2f9889921e | ||
|
|
8dba259796 | ||
|
|
783ca87acd | ||
|
|
71e78b4279 | ||
|
|
ac8aea7467 | ||
|
|
08d85a40c1 | ||
|
|
e247483d9b | ||
|
|
acac515d18 | ||
|
|
5273189315 | ||
|
|
fa192beac9 | ||
|
|
7aa322da35 | ||
|
|
574b84d843 | ||
|
|
0ffece7a0d | ||
|
|
46dac1a89f | ||
|
|
0adb2619dd | ||
|
|
40f4a705dc | ||
|
|
b40e2cc1db | ||
|
|
da058001b8 | ||
|
|
d7924d5ed0 | ||
|
|
5aa6dedce0 | ||
|
|
cf18521032 | ||
|
|
4bcba084db | ||
|
|
761a1a526c | ||
|
|
080de69543 | ||
|
|
29525bca87 | ||
|
|
94f42cf9c5 | ||
|
|
b910b03318 | ||
|
|
f0b5175155 | ||
|
|
a4d333b3d7 | ||
|
|
6ba3d1a38f | ||
|
|
56d83668d4 | ||
|
|
7c116571e3 | ||
|
|
b9ffa996c6 | ||
|
|
ed49c53969 | ||
|
|
185d9bcacb | ||
|
|
079098cdda | ||
|
|
d0f0fe0322 | ||
|
|
bf0d8077bb | ||
|
|
d52f698de3 | ||
|
|
4ef0cdc73f | ||
|
|
e6d338be13 | ||
|
|
8612f453d4 | ||
|
|
e1e7b5089b | ||
|
|
df60664506 | ||
|
|
87081ac926 | ||
|
|
4c8831d5df | ||
|
|
6b11e7ed34 | ||
|
|
4e8d841807 | ||
|
|
3cbda60277 | ||
|
|
03e5ae939e | ||
|
|
37a3aee9cc | ||
|
|
37f893abe6 | ||
|
|
f51dd131c1 | ||
|
|
4b90c60cc6 | ||
|
|
b26e9d3137 | ||
|
|
4ccad61e73 | ||
|
|
6fa1b1a3d0 | ||
|
|
caaa408618 | ||
|
|
bffe1927dd | ||
|
|
f933f774fc | ||
|
|
b658a68a09 | ||
|
|
6e2ccf7b7f | ||
|
|
fb39663293 | ||
|
|
54176983b6 | ||
|
|
deff7a34d0 | ||
|
|
e988427bb2 | ||
|
|
a1e7eeb309 | ||
|
|
97472b65ba | ||
|
|
c5a49537ed | ||
|
|
cefb96ddb4 | ||
|
|
9998b3784f | ||
|
|
39cad9efc7 | ||
|
|
efcafee774 | ||
|
|
6c55cbfe58 | ||
|
|
1ec7ea7e8e | ||
|
|
3301f00e57 | ||
|
|
895d00a499 | ||
|
|
28f3ac3878 | ||
|
|
2413ac3950 | ||
|
|
f85130a27a | ||
|
|
c85bb46373 | ||
|
|
0acfe3acc3 | ||
|
|
9c2a1ba0eb | ||
|
|
851141951d | ||
|
|
f9f88c8fb0 | ||
|
|
2bf9f1bca8 | ||
|
|
d9451f35dc | ||
|
|
05c0da53e6 | ||
|
|
c29680a75f | ||
|
|
eb7e74d36b | ||
|
|
390a712f14 | ||
|
|
9daea4c4d0 | ||
|
|
2e642815f4 | ||
|
|
c6d6b525ea | ||
|
|
fbb660504e | ||
|
|
1be96d1045 | ||
|
|
9bc02d61c2 | ||
|
|
21a64e52ab | ||
|
|
ad0b1b427d | ||
|
|
4c460cec1c | ||
|
|
1aa88de491 | ||
|
|
d0679ad72b | ||
|
|
5df70ce128 | ||
|
|
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 |
@@ -8,12 +8,19 @@ SQLITE_PATH=./data/homelab.db
|
||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
# Serve the UI under a subpath instead of the root of the origin, e.g. /homelab/.
|
||||
# Read by docker-compose at *build* time (docker compose build frontend), since the
|
||||
# prefix is baked into the frontend bundle. See INSTALLATION.md.
|
||||
# VITE_BASE_PATH=/homelab/
|
||||
|
||||
# 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 "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.
|
||||
# podman-compose does not strip those quotes the way Docker Compose does, so the
|
||||
# backend unwraps one matched pair itself — quoted or not, both work everywhere.
|
||||
AUTH_USERNAME=admin
|
||||
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
|
||||
|
||||
@@ -89,6 +96,10 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# 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)
|
||||
# Seconds to wait for the Z2M bridge to answer a networkmap request. Applies to
|
||||
# manual imports too. Raise it on a large mesh (200+ devices can take minutes)
|
||||
# if an import fails with "Timed out waiting for networkmap response".
|
||||
# ZIGBEE_NETWORKMAP_TIMEOUT=300
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
|
||||
# ZWAVE_MQTT_HOST=192.168.1.20
|
||||
@@ -99,3 +110,6 @@ MCP_SERVICE_KEY=svc_changeme
|
||||
# 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)
|
||||
# Seconds to wait for the gateway to answer an MQTT request. Same rationale as
|
||||
# ZIGBEE_NETWORKMAP_TIMEOUT above.
|
||||
# MQTT_RESPONSE_TIMEOUT=300
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@ 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
|
||||
buy_me_a_coffee: pouzor
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run a dependency audit, retrying only when the registry could not be reached.
|
||||
#
|
||||
# Both `npm audit` and `pip-audit` exit non-zero for two very different reasons:
|
||||
# a real advisory, and a registry that would not answer. Only the second is
|
||||
# worth a retry — the audit endpoints 503 often enough to redden PRs that
|
||||
# changed no dependency at all. An advisory fails on the first attempt, so a
|
||||
# vulnerability can never be retried into a pass.
|
||||
#
|
||||
# Usage: audit-with-retry.sh <command> [args...]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
ATTEMPTS=${AUDIT_ATTEMPTS:-3}
|
||||
DELAY=${AUDIT_RETRY_DELAY:-30}
|
||||
|
||||
# Transport and availability failures, from both npm and pip-audit/urllib3.
|
||||
NETWORK_FAILURE='audit endpoint returned an error|service unavailable|max retries exceeded|connectionerror|readtimeout|read timed out|temporary failure in name resolution|50[234] server error|etimedout|econnreset|enotfound|socket hang up|connection reset|remotedisconnected|urlopen error|timeouterror'
|
||||
|
||||
for attempt in $(seq 1 "$ATTEMPTS"); do
|
||||
if out=$("$@" 2>&1); then
|
||||
echo "$out"
|
||||
exit 0
|
||||
fi
|
||||
echo "$out"
|
||||
|
||||
if ! grep -qiE "$NETWORK_FAILURE" <<<"$out"; then
|
||||
echo "::error::$1 reported findings — see the output above"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::warning::$1 could not reach its registry (attempt $attempt/$ATTEMPTS)"
|
||||
if [ "$attempt" -lt "$ATTEMPTS" ]; then sleep "$DELAY"; fi
|
||||
done
|
||||
|
||||
echo "::error::$1 could not reach its registry after $ATTEMPTS attempts"
|
||||
exit 1
|
||||
@@ -0,0 +1,131 @@
|
||||
name: Install smoke test
|
||||
|
||||
# Runs scripts/install-baremetal.sh for real, in a clean debian:12 container.
|
||||
# ShellCheck reads the script but never executes it, so it cannot catch an
|
||||
# unintended `set -euo pipefail` abort — the failure class this job exists for.
|
||||
# debian:12 reproduces the two conditions that broke the installer once already:
|
||||
# the image carries no Node at all (so the version probe and the nodesource
|
||||
# install both run) and the job has no TTY (so the prompt fallbacks run).
|
||||
#
|
||||
# What it does not cover: systemd. A container has no PID 1 systemd, so
|
||||
# `systemctl` is stubbed out and the unit is written but never started. The
|
||||
# backend is booted by hand afterwards to prove the generated .env parses.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/install-baremetal.sh'
|
||||
- 'backend/requirements.txt'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/install-smoke.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/install-baremetal.sh'
|
||||
- 'backend/requirements.txt'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/install-smoke.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
baremetal:
|
||||
runs-on: ubuntu-latest
|
||||
container: debian:12
|
||||
timeout-minutes: 25
|
||||
# Without this the runner hands container steps `sh -e {0}` — dash, where
|
||||
# `set -o pipefail` and `[[ … ]]` are syntax errors.
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Install checkout prerequisites
|
||||
run: |
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq git ca-certificates >/dev/null
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Confirm the container reproduces a fresh host
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --version || echo "no node — the nodesource path will run"
|
||||
[ -t 0 ] && echo "TTY present (unexpected)" || echo "no TTY — prompt fallbacks will run"
|
||||
|
||||
- name: Stub systemctl
|
||||
# No PID 1 systemd in a container. The stub logs the calls so the run
|
||||
# shows which unit actions the script attempted.
|
||||
run: |
|
||||
printf '#!/bin/sh\necho "[systemctl stub] $*"\n' > /usr/local/bin/systemctl
|
||||
chmod +x /usr/local/bin/systemctl
|
||||
|
||||
- name: Run the installer
|
||||
# INSTALL_DIR is the checkout, so the clone is skipped and the script
|
||||
# installs the tree under test rather than main. SKIP_NGINX because the
|
||||
# front end is not what this job is asserting.
|
||||
run: |
|
||||
INSTALL_DIR="$GITHUB_WORKSPACE" \
|
||||
SKIP_NGINX=1 \
|
||||
ADMIN_PASSWORD=ci-smoke-password \
|
||||
SCANNER_RANGES='["10.0.0.0/24"]' \
|
||||
bash scripts/install-baremetal.sh
|
||||
|
||||
- name: The generated .env is well formed
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f backend/.env
|
||||
grep -qE '^SECRET_KEY=[0-9a-f]{64}$' backend/.env
|
||||
grep -qE '^AUTH_PASSWORD_HASH=.\$2b\$' backend/.env
|
||||
# JSON values must stay single-quoted: systemd's EnvironmentFile
|
||||
# parser strips bare double quotes, which would break the JSON.
|
||||
grep -qE "^CORS_ORIGINS='\\[" backend/.env
|
||||
grep -qE "^SCANNER_RANGES='\\[" backend/.env
|
||||
grep -q "^SQLITE_PATH=$GITHUB_WORKSPACE/data/homelab.db$" backend/.env
|
||||
test "$(stat -c '%a' backend/.env)" = "600"
|
||||
|
||||
- name: The build artefacts and service user exist
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -x backend/.venv/bin/uvicorn
|
||||
test -f frontend/dist/index.html
|
||||
id homelable
|
||||
|
||||
- name: The systemd unit was written
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f /etc/systemd/system/homelable.service
|
||||
grep -q 'ExecStart=.*/uvicorn app.main:app --host 127.0.0.1 --port 8000' \
|
||||
/etc/systemd/system/homelable.service
|
||||
grep -q "EnvironmentFile=$GITHUB_WORKSPACE/backend/.env" \
|
||||
/etc/systemd/system/homelable.service
|
||||
|
||||
- name: The backend boots on the generated config
|
||||
# The unit's own ExecStart, run by hand. Proves the .env pydantic reads
|
||||
# is valid — the assertion that catches config drift.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd backend
|
||||
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 &
|
||||
for _ in $(seq 1 30); do
|
||||
if curl -fsS http://127.0.0.1:8000/api/v1/health >/dev/null 2>&1; then
|
||||
echo "backend healthy"
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "backend did not answer /api/v1/health within 30s" >&2
|
||||
exit 1
|
||||
|
||||
- name: Re-running is idempotent and keeps .env
|
||||
run: |
|
||||
set -euo pipefail
|
||||
before="$(sha256sum backend/.env)"
|
||||
INSTALL_DIR="$GITHUB_WORKSPACE" SKIP_NGINX=1 \
|
||||
bash scripts/install-baremetal.sh | tee /tmp/run2.log
|
||||
after="$(sha256sum backend/.env)"
|
||||
test "$before" = "$after" || { echo "second run rewrote backend/.env" >&2; exit 1; }
|
||||
grep -q 'keeping existing values' /tmp/run2.log
|
||||
@@ -61,10 +61,13 @@ jobs:
|
||||
- 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.
|
||||
# Also guards the copies the MCP server keeps of frontend data against drift:
|
||||
# NODE_TYPES / EDGE_TYPES in mcp/app/tools.py against frontend/src/types/index.ts,
|
||||
# and the faceplate catalog in mcp/app/faceplates.py against
|
||||
# frontend/src/rack/faceplates.ts (test_node_types_sync.py,
|
||||
# test_edge_types_sync.py, test_faceplates_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:
|
||||
|
||||
@@ -22,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
|
||||
@@ -34,9 +40,17 @@ jobs:
|
||||
# --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
|
||||
#
|
||||
# Wrapped so a registry outage retries instead of failing the PR; an
|
||||
# advisory still fails on the first attempt. See the script's header.
|
||||
working-directory: frontend
|
||||
run: "$GITHUB_WORKSPACE/.github/scripts/audit-with-retry.sh npm audit --omit=dev --audit-level=high"
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Pip audit
|
||||
run: pip install pip-audit && pip-audit -r backend/requirements.txt
|
||||
# pip-audit queries PyPI's advisory API and fails the same way npm does
|
||||
# when it cannot reach it, so it gets the same wrapper.
|
||||
run: |
|
||||
pip install pip-audit
|
||||
"$GITHUB_WORKSPACE/.github/scripts/audit-with-retry.sh" pip-audit -r backend/requirements.txt
|
||||
|
||||
+138
@@ -5,6 +5,144 @@ 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.4.1] - 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
- Duplicate Device Inventory rows can be merged. A **Merge** action in the inventory's select mode lets you pick the survivor — the only way to collapse rows that share no address at all, since a name is not an identity — and `reconcile_duplicates` now runs at the end of a Proxmox import and of a scan to collapse what a shared MAC proves (never a shared IP alone, never across two distinct IEEEs). Merging keeps everything: the survivor fills its gaps from the others, addresses, services, properties and sources union, and canvas nodes, rack mounts, documents and mesh links are re-pointed before the extra rows go. (#424)
|
||||
|
||||
### Fixes
|
||||
|
||||
- `dedupe_nodes_by_device` deleted duplicate nodes while `rack_devices.node_id` and `documents.node_id` still named them; SQLite runs with foreign keys off, so the declared `ON DELETE SET NULL` never fired. (#424)
|
||||
- The scanner's duplicate collapse merges instead of deleting inventory rows outright, which lost their facts and orphaned whatever drew them. Its guard against removing a second approved row is unchanged. (#424)
|
||||
- The merge dialog is wide enough to read an IEEE. The addresses are what the choice is made on, and a long name was clipped at the edge of the dialog; the address line now wraps instead of truncating, and the list grows with the viewport. (#424)
|
||||
|
||||
## [3.4.0] - 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
- **Documentation** — a real document space for the homelab, beside the canvases. Markdown documents attached to a device, a node, a canvas, or standing on their own; a Library of folders, a Devices tree pivoted by zone, group, type, subnet, rack, vendor or tag, wiki-links between documents, full-text search (FTS5, with a `LIKE` fallback where SQLite ships without it), up to 50 revisions per document with a line diff and restore, backlinks under every body, tags, and a "needs attention" bucket driven by a per-document review interval. A device document is scaffolded once from the scan facts and then belongs to you — nothing rewrites it — with an explicit "device data changed" banner and a per-block regenerate when you want the new facts. The old `device_inventory.notes` are migrated in verbatim and left in place. (#418, #421)
|
||||
- Documents are reachable from where you already are: a node's detail panel, a device's inventory modal, and the canvas search modal, which now finds documents too. Wiki-links are written from a picker instead of from memory. (#418, #421)
|
||||
- The rack faceplate is a real, editable object, and the **Device Inventory owns it**: plate, U height, column span, colour and the port list — with each port placed by hand — are carried by the device, so it wears the same panel in every rack it is mounted in. Only placement, pinned status and label stay per canvas. A device's front panel can also be drawn and edited straight from its inventory row. (#405)
|
||||
- A rack mount chooses when it shows its ports — `auto`, `always` or `hover` — and a cable can no longer end on a plate drawing no socket. (#407)
|
||||
- Proxmox guests can be nested inside their host on the canvas instead of sitting beside it on virtual edges. Both entry points ask: the import modal and adding a Proxmox host from the Device Inventory. (#399)
|
||||
- Homelable can be served under a subpath. One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base` and is threaded through every hand-built URL, the status WebSocket, the OIDC login and callback, and both shipped nginx configs. Defaults to `/`, where nothing changes. (#412)
|
||||
- The MCP server covers the rack canvas and the Device Inventory writes — 24 tools to 47. Racks, mounts, accessories, moves, faceplates and cables, plus create/edit/delete on inventory entries. (#414)
|
||||
- The Homelable logo is in the brand icon picker, first in the catalog, for both node and service icons. (#411)
|
||||
|
||||
### Fixes
|
||||
|
||||
- The scanner and the status checker no longer send an HTTP `GET` to TCP/9100. A printer speaking JetDirect printed that request as a page of plaintext — once a minute, forever, for an approved node. LPD (515) and JetDirect (9100-9107) are now on the non-HTTP denylist. Trade-off: Prometheus Node Exporter on 9100 loses its status check and shows grey. (#406)
|
||||
- A device declaring several IPs is matched on every one of them instead of coming back as a new pending row on each scan. Hiding it hides it at all of its addresses, and the deduplication collapses the duplicates an extra address had already spawned — never deleting a second approved row. (#416)
|
||||
- A Proxmox import no longer overwrites one guest's inventory row with another's facts. Identity is now the synthetic per-guest ieee first, then NIC MAC, then IP — and the IP fallback skips rows already claimed by a guest, since a shared VIP, a re-used lease or a CNI bridge address describes several machines. A migrated or renamed guest keeps its row. (#420)
|
||||
- Coming back to the canvas from Documentation keeps the pan and zoom you left it at, instead of remounting zoomed in at the origin. (#422)
|
||||
- Switching back to a rack canvas restores its saved pan and zoom instead of shrinking the racks into a corner until a hard reload. (#409)
|
||||
- The basic edge animation runs the same way whichever direction the nodes were dragged in; the dash direction was derived from the source and target's on-screen geometry. (#402)
|
||||
- Login works under `podman-compose`. Quoted `AUTH_PASSWORD_HASH`, `SECRET_KEY` and `OIDC_CLIENT_SECRET` values arrive with their quotes attached, which Docker Compose strips and podman-compose does not; a matched pair of surrounding quotes is now unwrapped in the backend, so one `.env` works under either runtime. (#413)
|
||||
- A zone or group description is kept on save. (#418)
|
||||
- MCP node properties are keyed on `key`, not `name`, so they no longer collapse silently. (#414)
|
||||
- Eight Dependabot advisories cleared in the frontend lockfile (`fast-uri`, `@humanfs/node`, `qs`, `nanoid`); all dev-only, `npm audit` reports zero. (#417)
|
||||
- The npm and pip audit CI jobs retry a registry outage instead of failing the run. (#409)
|
||||
|
||||
### Docs
|
||||
|
||||
- README and FEATURES.md cover the Documentation section, and `docs/documentation.md` documents the tree, wiki-links, search, history, backlinks and the API. (#421)
|
||||
- `.env.example` notes that quoting a secret is optional and works either way. (#413)
|
||||
|
||||
## [3.3.5] - 2026-08-21
|
||||
|
||||
### Features
|
||||
|
||||
- Deep-scan a single device from its inventory detail: the Services section gets a **Deep scan** button that sweeps the device over a port range you pick, prefilled with the full 1-65535 range and narrowable when you know where the service lives. The run is a normal Scan Run, so stop, progress and Scan History work as usual, and the fresh services are merged in — never replacing what you added by hand. (#363)
|
||||
|
||||
### Fixes
|
||||
|
||||
- A deep rescan of a slow host no longer comes back empty. The run hit its timeout and nmap discarded every port it had already found; the full range is now scanned in slices, a partial sweep is reported as partial instead of passed off as complete, and the timeout is a total budget between slices rather than an nmap flag. (#363)
|
||||
- A scan no longer repaints the service icons you picked by hand: the fingerprint's guess overwrote your choice, and cleared it outright on a port no signature covers. Since 3.3.0 the inventory row is the only copy, so one scan changed the service on every canvas at once. (#363)
|
||||
- A rescan finishing while you edit a device no longer throws the edit away, and it no longer tags every device it touches as network-discovered — a Proxmox guest, a rack mount or a hand-added host keeps its own source. A failed run is also recorded as `error`, the status Scan History filters and colours. (#363)
|
||||
- A property with no value gets the full node width for its label instead of being truncated against empty space. (#362)
|
||||
- A same-origin OIDC deployment behind a reverse proxy no longer answers 403 to every write. The CSRF check validated the browser's `Origin` against `CORS_ORIGINS` alone; the app's own origin, taken from `OIDC_REDIRECT_URI`, is accepted too, and startup warns when `CORS_ORIGINS` omits it. (#360)
|
||||
|
||||
## [3.3.4] - 2026-08-17
|
||||
|
||||
### Fixes
|
||||
|
||||
- A network scan no longer deletes the services you added by hand. The scanner replaced a device's whole service list with what it fingerprinted, so one scan wiped manual entries on every canvas at once and brought back the ones you had deleted. It merges now. (#347)
|
||||
- A node whose services were all replaced under it no longer renders empty: when nothing a node's view names is left on the device, the device's own list is drawn instead of hiding everything. (#347)
|
||||
- Properties added while running 3.3.0–3.3.2 survive the 3.3.3 upgrade. The view recovered from the pre-3.3.0 backup could not know about them, so they were hidden on every canvas; they are kept visible, and the per-canvas arrangement the backup does know is still restored. (#347)
|
||||
|
||||
## [3.3.3] - 2026-08-17
|
||||
|
||||
### Features
|
||||
|
||||
- Each node now keeps its own view of a device's services and properties: order and visibility are per node, so a service the scanner fingerprinted no longer appears on every canvas drawing that device, and a property added on one schematic stays there. The facts still live on the inventory row — hiding is per node, deleting is device-wide. Upgrades keep what each canvas showed today. (#357)
|
||||
|
||||
### Fixes
|
||||
|
||||
- The one-off view seed no longer re-opens the pre-upgrade backup on every boot: a node pointing at a deleted device kept matching the seed query and logged a recovery that recovered nothing. (#357)
|
||||
|
||||
## [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
|
||||
|
||||
+30
-8
@@ -7,6 +7,12 @@ FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
ARG VITE_STANDALONE=false
|
||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||
|
||||
# Subpath support: build with VITE_BASE_PATH=/homelab/ to serve Homelable under a
|
||||
# prefix instead of the root of an origin. The default '/' produces the same build
|
||||
# as before the option existed.
|
||||
ARG VITE_BASE_PATH=/
|
||||
ENV VITE_BASE_PATH=$VITE_BASE_PATH
|
||||
|
||||
WORKDIR /app
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
@@ -18,15 +24,31 @@ RUN npm run build
|
||||
# Stage 2: serve
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY --from=builder /app/dist /tmp/dist
|
||||
|
||||
# Nginx config: use standalone config (no backend proxy) or full config
|
||||
# Nginx config: standalone (no backend proxy) or full, served at the root or under the
|
||||
# VITE_BASE_PATH prefix. At the root the build lands in /usr/share/nginx/html and the
|
||||
# shipped .conf is copied verbatim, so an existing deployment gets exactly what it had
|
||||
# before this option existed. Under a prefix the build lands in the matching
|
||||
# subdirectory, which keeps the generated config on plain `root` semantics.
|
||||
ARG VITE_STANDALONE=false
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/nginx.standalone.conf /etc/nginx/conf.d/nginx.standalone.conf
|
||||
RUN if [ "$VITE_STANDALONE" = "true" ]; then \
|
||||
cp /etc/nginx/conf.d/nginx.standalone.conf /etc/nginx/conf.d/default.conf; \
|
||||
fi && \
|
||||
rm /etc/nginx/conf.d/nginx.standalone.conf
|
||||
ARG VITE_BASE_PATH=/
|
||||
COPY docker/nginx.conf docker/nginx.standalone.conf /tmp/nginx-conf/
|
||||
COPY docker/nginx.subpath.conf.template docker/nginx.standalone.subpath.conf.template /tmp/nginx-conf/
|
||||
# ash with pipefail: the base-path normalization below pipes printf into sed.
|
||||
SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
|
||||
RUN set -eu; \
|
||||
if [ "$VITE_STANDALONE" = "true" ]; then variant=nginx.standalone; else variant=nginx; fi; \
|
||||
base=$(printf '%s' "${VITE_BASE_PATH:-/}" | sed -e 's#^/*#/#' -e 's#/*$#/#' -e 's#//*#/#g'); \
|
||||
[ -n "$base" ] || base=/; \
|
||||
mkdir -p "/usr/share/nginx/html${base}"; \
|
||||
cp -a /tmp/dist/. "/usr/share/nginx/html${base}"; \
|
||||
if [ "$base" = "/" ]; then \
|
||||
cp "/tmp/nginx-conf/${variant}.conf" /etc/nginx/conf.d/default.conf; \
|
||||
else \
|
||||
sed -e "s#__BASE_NO_SLASH__#${base%/}#g" -e "s#__BASE__#${base}#g" \
|
||||
"/tmp/nginx-conf/${variant}.subpath.conf.template" > /etc/nginx/conf.d/default.conf; \
|
||||
fi; \
|
||||
rm -rf /tmp/dist /tmp/nginx-conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
+68
-29
@@ -16,20 +16,22 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
2. [Groups & Nesting](#2-groups--nesting)
|
||||
3. [Text Annotations](#3-text-annotations)
|
||||
4. [Multiple Canvases](#4-multiple-canvases)
|
||||
5. [Customize Style](#5-customize-style)
|
||||
6. [Floor Plan](#6-floor-plan-)
|
||||
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
|
||||
8. [Zigbee Import](#8-zigbee-import-)
|
||||
9. [Z-Wave Import](#9-z-wave-import-)
|
||||
10. [Proxmox VE Import](#10-proxmox-ve-import-)
|
||||
11. [Device Inventory](#11-device-inventory-)
|
||||
12. [Live Status Monitoring](#12-live-status-monitoring-)
|
||||
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
|
||||
14. [Live View (read-only public canvas)](#14-live-view-)
|
||||
15. [Gethomepage Widget](#15-gethomepage-widget-)
|
||||
16. [MCP Server (AI integration)](#16-mcp-server-)
|
||||
17. [Settings & Shortcuts](#17-settings--shortcuts)
|
||||
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
|
||||
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. [Documentation](#13-documentation-)
|
||||
14. [Live Status Monitoring](#14-live-status-monitoring-)
|
||||
15. [Export (PNG / SVG / YAML / Markdown)](#15-export)
|
||||
16. [Live View (read-only public canvas)](#16-live-view-)
|
||||
17. [Gethomepage Widget](#17-gethomepage-widget-)
|
||||
18. [MCP Server (AI integration)](#18-mcp-server-)
|
||||
19. [Settings & Shortcuts](#19-settings--shortcuts)
|
||||
20. [Authentication (Local / OpenID Connect)](#20-authentication-local--openid-connect-)
|
||||
|
||||
---
|
||||
|
||||
@@ -74,7 +76,26 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 5. Customize Style
|
||||
## 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.
|
||||
|
||||
@@ -85,7 +106,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 6. Floor Plan 🔒
|
||||
## 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.
|
||||
|
||||
@@ -96,7 +117,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
|
||||
---
|
||||
|
||||
## 7. Network Scanner (IP import) 🔒
|
||||
## 8. Network Scanner (IP import) 🔒
|
||||
|
||||
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
|
||||
|
||||
@@ -116,7 +137,7 @@ SCANNER_HTTP_VERIFY_TLS=false
|
||||
|
||||
---
|
||||
|
||||
## 8. Zigbee Import 🔒
|
||||
## 9. Zigbee Import 🔒
|
||||
|
||||
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
|
||||
|
||||
@@ -129,7 +150,7 @@ Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. Th
|
||||
|
||||
---
|
||||
|
||||
## 9. Z-Wave Import 🔒
|
||||
## 10. Z-Wave Import 🔒
|
||||
|
||||
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
|
||||
|
||||
@@ -142,7 +163,7 @@ Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy c
|
||||
|
||||
---
|
||||
|
||||
## 10. Proxmox VE Import 🔒
|
||||
## 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.
|
||||
|
||||
@@ -156,7 +177,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 11. Device Inventory 🔒
|
||||
## 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.
|
||||
|
||||
@@ -167,7 +188,25 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 12. Live Status Monitoring 🔒
|
||||
## 13. Documentation 🔒
|
||||
|
||||
**What:** A markdown documentation space for the whole lab. Every device gets a document written once from what the scan actually found — identity, hardware, services, network, operations, troubleshooting — and it is yours from there; nothing rewrites it behind you. Next to it, a Library of pages you write yourself: runbooks, incidents, decisions, a network overview.
|
||||
|
||||
**Use:**
|
||||
- Sidebar → **Documentation**. The **Devices** root re-pivots instantly by zone, group, type, physical/virtual, subnet, rack, vendor, discovery source, status, tag or A–Z; picking a device with no document writes one from its facts.
|
||||
- The **Library** is folders you make. **New page** / **New folder** in the header, drag an item to file it. Templates: blank, runbook, service, network overview, incident, procedure, decision (ADR), zone.
|
||||
- Editing is plain markdown and **saving is explicit**, like the canvas. An unsaved body is kept in your browser and offered back if you close the tab. `/` in the editor inserts a freshly generated block — services, hardware, network, rack — from the device's current data.
|
||||
- **Link documents** with `[[VLAN plan]]`, `[[device:nas-01]]` or `[[doc:slug|label]]`. Typing `[[` opens a picker that writes the link for you. A link to a document that does not exist yet renders red and offers to create it, and every document lists **Linked from** at the bottom — who points here.
|
||||
- **History**: the clock-arrow button in the header lists earlier versions, up to 50 per document. Read one, hit **Changes** for a line-by-line diff against the current body, **Restore** to bring it back — the body it replaces is saved to the history first.
|
||||
- **Keeping it honest**: `review_every: 6m` in a document's frontmatter badges it "due for review" when it lapses; a **device data changed** banner appears when the device has moved on since the document was written; **Regenerate** rebuilds a device document from scratch (old body kept in the history). Tags are chips under the title.
|
||||
- **Search** the whole space from the box above the tree — title, tags and body. The canvas' own search (Ctrl/Cmd+K) finds documents too, and picking one jumps here.
|
||||
- Coming from the old per-device **Notes** field? A banner offers to migrate every device that has one into a document. It copies, it does not move: the `notes` field is left exactly as it was.
|
||||
|
||||
> **Full documentation:** [docs/documentation.md](./docs/documentation.md)
|
||||
|
||||
---
|
||||
|
||||
## 14. Live Status Monitoring 🔒
|
||||
|
||||
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
|
||||
|
||||
@@ -188,7 +227,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 13. Export
|
||||
## 15. Export
|
||||
|
||||
**What:** Get your canvas out as a picture or as structured data.
|
||||
|
||||
@@ -200,7 +239,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 14. Live View 🔒
|
||||
## 16. Live View 🔒
|
||||
|
||||
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
|
||||
|
||||
@@ -212,7 +251,7 @@ Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab
|
||||
|
||||
---
|
||||
|
||||
## 15. Gethomepage Widget 🔒
|
||||
## 17. Gethomepage Widget 🔒
|
||||
|
||||
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
|
||||
|
||||
@@ -224,7 +263,7 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
|
||||
|
||||
---
|
||||
|
||||
## 16. MCP Server 🔒
|
||||
## 18. 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.
|
||||
|
||||
@@ -238,11 +277,11 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
|
||||
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).
|
||||
The AI can list nodes/edges/canvas/zones/designs/inventory/scans, add/update/delete nodes, edges and zones, kick off scans, triage and edit inventory entries, and work a rack canvas — create racks, mount and move gear, patch cables. Keep port 8001 firewalled to your LAN. Full setup in the [README](./README.md#mcp-server-ai-integration-optional).
|
||||
|
||||
---
|
||||
|
||||
## 17. Settings & Shortcuts
|
||||
## 19. Settings & Shortcuts
|
||||
|
||||
**What:** App config and keyboard shortcuts.
|
||||
|
||||
@@ -253,7 +292,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 18. Authentication (Local / OpenID Connect) 🔒
|
||||
## 20. 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.
|
||||
|
||||
+297
@@ -40,8 +40,75 @@ 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`.
|
||||
|
||||
## Scanning from Docker — MAC addresses
|
||||
|
||||
Out of the box the backend runs on a Docker bridge network, and **the scan will
|
||||
never report a MAC address** from it. MACs come from ARP, which is layer 2: the
|
||||
container's ARP cache holds only the Docker gateway, and nmap can read a target's
|
||||
hardware address only when that target sits in the same broadcast domain. From a
|
||||
bridge every LAN host is one hop away behind the gateway, so the field stays
|
||||
empty. `cap_add: NET_RAW` does not change this — the capability grants raw
|
||||
sockets, not a place on the LAN.
|
||||
|
||||
This also affects device identity. When a device is rescanned it is matched on
|
||||
MAC first and IP second, so without MACs a DHCP lease change makes the device
|
||||
come back as a new entry in the inventory instead of updating the old one.
|
||||
|
||||
To collect MACs, put the backend on the LAN itself. In `docker-compose.yml` (or
|
||||
`docker-compose.prebuilt.yml`), comment out the backend's `networks:` key and
|
||||
uncomment:
|
||||
|
||||
```yaml
|
||||
network_mode: host
|
||||
```
|
||||
|
||||
then `docker compose up -d`. Caveats:
|
||||
|
||||
- **Linux only.** On Docker Desktop for macOS and Windows the containers run
|
||||
inside a VM, so host networking still does not reach your physical LAN. There
|
||||
is no MAC-capable Docker setup on those platforms — run the
|
||||
[bare-metal install](#bare-metal--no-docker) instead.
|
||||
- The backend binds `8000` directly on the host, with no port mapping and no
|
||||
network isolation from other host services.
|
||||
- `frontend` and `mcp` reach the backend at `http://backend:8000` over the
|
||||
`homelable` bridge; once the backend leaves that network they need
|
||||
`http://127.0.0.1:8000` instead. Set `BACKEND_URL` on `mcp`, and for the front
|
||||
end either give it `network_mode: host` too or point its nginx proxy at the
|
||||
host address.
|
||||
|
||||
The alternative, if you would rather keep the backend isolated, is a **macvlan**
|
||||
network, which gives the container its own MAC and IP on your physical LAN.
|
||||
It needs a parent interface and a spare address range from your subnet, and on
|
||||
most setups the Docker host itself cannot talk to a macvlan container without an
|
||||
extra shim interface.
|
||||
|
||||
## 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
|
||||
@@ -64,6 +131,236 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
|
||||
|
||||
---
|
||||
|
||||
## Bare metal — no Docker
|
||||
|
||||
`scripts/install-baremetal.sh` installs Homelable natively on a Debian 12+ /
|
||||
Ubuntu 22.04+ host (physical, VM or LXC): a Python venv and a `homelable`
|
||||
systemd unit for the backend on `127.0.0.1:8000`, the built frontend served by
|
||||
nginx on port 3000.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Pouzor/homelable.git /opt/homelable
|
||||
sudo bash /opt/homelable/scripts/install-baremetal.sh
|
||||
```
|
||||
|
||||
It prompts for the admin password and the CIDR range to scan, then writes
|
||||
`backend/.env` with a generated `SECRET_KEY` and bcrypt hash. Open
|
||||
**http://\<host-ip\>:3000**.
|
||||
|
||||
The script can also clone for you — run it from anywhere and it fetches the repo
|
||||
into `INSTALL_DIR` when that directory is empty. Piped into `bash` there is no
|
||||
terminal to prompt on, so pass the two answers as environment variables:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-baremetal.sh \
|
||||
| sudo ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["192.168.1.0/24"]' bash
|
||||
```
|
||||
|
||||
Without them the prompts are skipped and their defaults apply — the password
|
||||
becomes `admin` and the range is guessed from the primary interface. The script
|
||||
warns when that happens; change the password before exposing the host.
|
||||
|
||||
Re-running is safe and is how you upgrade — an existing `backend/.env` is kept
|
||||
untouched, everything else is rebuilt:
|
||||
|
||||
```bash
|
||||
cd /opt/homelable && git pull
|
||||
sudo bash scripts/install-baremetal.sh
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
Every setting is an environment variable. Setting them all skips every prompt,
|
||||
which is what makes an unattended install possible:
|
||||
|
||||
```bash
|
||||
sudo HTTP_PORT=8080 ADMIN_PASSWORD=hunter2 SCANNER_RANGES='["10.0.0.0/24"]' \
|
||||
bash scripts/install-baremetal.sh
|
||||
```
|
||||
|
||||
| Variable | Default | What |
|
||||
|---|---|---|
|
||||
| `INSTALL_DIR` | `/opt/homelable` | Repo root |
|
||||
| `REPO_URL` / `REPO_REF` | upstream / `main` | Used only when `INSTALL_DIR` is empty |
|
||||
| `SERVICE_USER` | `homelable` | systemd `User=` |
|
||||
| `BACKEND_PORT` | `8000` | uvicorn port, bound to loopback |
|
||||
| `HTTP_PORT` | `3000` | nginx port |
|
||||
| `SERVER_NAME` | `_` | nginx `server_name` |
|
||||
| `ADMIN_PASSWORD` | prompt, else `admin` | Initial password for user `admin` |
|
||||
| `SCANNER_RANGES` | prompt, else guessed | JSON array of CIDRs |
|
||||
| `SKIP_NGINX=1` | off | Do not install or touch nginx |
|
||||
| `BASE_PATH` | `/` | Serve under a subpath — see [Serving under a subpath](#serving-under-a-subpath) |
|
||||
|
||||
### Afterwards
|
||||
|
||||
```bash
|
||||
systemctl status homelable
|
||||
journalctl -u homelable -f
|
||||
```
|
||||
|
||||
- Config: `/opt/homelable/backend/.env` — every other option (OIDC, MCP,
|
||||
Proxmox, Zigbee, Z-Wave, live view) is documented in `.env.example`.
|
||||
`systemctl restart homelable` after an edit.
|
||||
- Data: `/opt/homelable/data` — SQLite DB and uploads. Back up this folder.
|
||||
- nginx site: `/etc/nginx/sites-available/homelable`.
|
||||
|
||||
Change the password later:
|
||||
|
||||
```bash
|
||||
/opt/homelable/backend/.venv/bin/python -c \
|
||||
'import bcrypt; print(bcrypt.hashpw(b"newpassword", bcrypt.gensalt()).decode())'
|
||||
# put it in backend/.env as AUTH_PASSWORD_HASH='$2b$12$...' (keep the single quotes)
|
||||
systemctl restart homelable
|
||||
```
|
||||
|
||||
⚠️ Keep JSON values in `backend/.env` single-quoted — `CORS_ORIGINS='["http://…"]'`.
|
||||
systemd's `EnvironmentFile` parser strips bare double quotes, which breaks the
|
||||
JSON before the backend parses it. This does not apply to the Docker install.
|
||||
|
||||
### Scanning as a non-root service
|
||||
|
||||
The unit runs as `homelable`, not root, so nmap has no raw sockets and silently
|
||||
falls back to a TCP connect scan: hosts and open ports are still found, OS
|
||||
detection (`-O`) and SYN scan (`-sS`) are not. To grant them, uncomment in
|
||||
`/etc/systemd/system/homelable.service`:
|
||||
|
||||
```ini
|
||||
AmbientCapabilities=CAP_NET_RAW CAP_NET_ADMIN
|
||||
CapabilityBoundingSet=CAP_NET_RAW CAP_NET_ADMIN
|
||||
```
|
||||
|
||||
then `systemctl daemon-reload && systemctl restart homelable`.
|
||||
|
||||
### Your own reverse proxy
|
||||
|
||||
With `SKIP_NGINX=1` the script leaves the front end to you: serve
|
||||
`/opt/homelable/frontend/dist` as a static SPA and proxy the API to the backend.
|
||||
The nginx translation of `docker/nginx.conf` — what the script writes, with
|
||||
`backend:8000` replaced by `127.0.0.1:8000`:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /opt/homelable/frontend/dist;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 20M;
|
||||
|
||||
# WebSocket — must come before /api/ to take priority
|
||||
location /api/v1/status/ws/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Legacy /ws/ path
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Terminating TLS in front of it means adding your own hostname to
|
||||
`CORS_ORIGINS` in `backend/.env` (`https://homelable.example`) and restarting
|
||||
the service.
|
||||
|
||||
---
|
||||
|
||||
## Serving under a subpath
|
||||
|
||||
By default Homelable owns the root of its origin (`https://homelable.example/`).
|
||||
To put it behind an existing reverse proxy on a shared hostname — one cert, one
|
||||
dynamic-DNS name, one open port, every service on its own prefix — build it with
|
||||
a base path.
|
||||
|
||||
The base path is **baked into the build**: the browser has no way to guess it, so
|
||||
it cannot be a runtime setting. Changing it means rebuilding the frontend.
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# in .env, next to the backend settings
|
||||
VITE_BASE_PATH=/homelab/
|
||||
|
||||
docker compose build frontend && docker compose up -d
|
||||
```
|
||||
|
||||
Homelable then answers on `http://<host>:3000/homelab/`. The generated nginx
|
||||
config accepts both reverse-proxy styles, so either of these works in front of
|
||||
it:
|
||||
|
||||
```nginx
|
||||
# prefix forwarded intact — no trailing slash on proxy_pass
|
||||
location /homelab/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# or: prefix stripped — trailing slash on proxy_pass
|
||||
location /homelab/ {
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The pre-built images (`docker-compose.prebuilt.yml`) are built for the root, so a
|
||||
subpath needs a local build.
|
||||
|
||||
### Bare metal / LXC
|
||||
|
||||
```bash
|
||||
sudo BASE_PATH=/homelab/ bash scripts/install-baremetal.sh
|
||||
```
|
||||
|
||||
The script bakes the prefix into the build and writes the matching nginx site.
|
||||
The build stays in `/opt/homelable/frontend/dist`; the site serves it through
|
||||
`/var/www/homelable/homelab`, a symlink refreshed on every run. Re-running the
|
||||
script with a different `BASE_PATH` (or none) rewrites both.
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
cd frontend && VITE_BASE_PATH=/homelab/ npm run dev # http://localhost:5173/homelab/
|
||||
```
|
||||
|
||||
The Vite dev proxy follows the same prefix and strips it before forwarding to
|
||||
uvicorn on `:8000`.
|
||||
|
||||
### What to expect
|
||||
|
||||
- WebSocket status updates, uploaded floor plans and the read-only live view
|
||||
(`/homelab/view`) all follow the prefix.
|
||||
- TLS in front still means adding your hostname to `CORS_ORIGINS` in
|
||||
`backend/.env`, exactly as at the root.
|
||||
- OIDC: `OIDC_REDIRECT_URI` must carry the prefix
|
||||
(`https://home.example/homelab/api/v1/auth/oidc/callback`), and so must the
|
||||
redirect URI registered with your provider.
|
||||
- Floor plans uploaded before the move keep working — stored URLs are resolved
|
||||
against the base path at render time.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is done via `.env` (copied from `.env.example`):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
||||
<strong>Self-hosted homelab infrastructure visualization, documentation, scanning & live monitoring</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -21,6 +21,8 @@
|
||||
<a href="#screenshots">Screenshots</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#installation">Installation</a> ·
|
||||
<a href="#documentation">Documentation</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> ·
|
||||
@@ -30,10 +32,12 @@
|
||||
|
||||
## 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 is a self-hosted infrastructure visualization solution. It provides a network/zigbee/zwave 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.
|
||||
|
||||
Every device also gets a **document** — written for you from what the scan found, and yours to maintain from there — next to a Library of pages you write yourself: runbooks, incidents, a network overview. Your homelab stops being documented in a wiki somewhere else.
|
||||
|
||||
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.
|
||||
@@ -47,8 +51,8 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
|
||||
<p align="center">
|
||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||
<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" />
|
||||
<img alt="Rack display in homelable" src="https://github.com/user-attachments/assets/43273605-4f46-4163-8aea-8bf8a76a3f76" />
|
||||
<img alt="Documentation in Homelable" src="https://github.com/user-attachments/assets/4310bd8a-769b-4640-af84-085dc6d8b9ac" />
|
||||
|
||||
</p>
|
||||
|
||||
@@ -56,7 +60,7 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
|
||||
|
||||
## Features
|
||||
|
||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
|
||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, **rack canvases** with port-to-port patching, a **markdown documentation space** for the whole lab, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps, documents and watches your whole homelab.
|
||||
|
||||
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
|
||||
|
||||
@@ -64,7 +68,47 @@ Every feature, with how to turn it on and use it, is described in **[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, **[bare metal without Docker](./INSTALLATION.md#bare-metal--no-docker)** (`sudo bash scripts/install-baremetal.sh` — systemd unit plus nginx on a Debian/Ubuntu host), 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)
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
Homelable keeps its own markdown documentation space, so the lab is described where it is drawn. Sidebar → **Documentation**.
|
||||
|
||||
Every device has a document, generated once from what the scan actually found — identity, hardware, one section per service, network, operations, troubleshooting — and never rewritten behind you. Beside it, a **Library** of pages you write: runbooks, incidents, decisions, a network overview, from a template or blank.
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Pick a device** in the tree — one with no document yet gets one written from its facts. Re-pivot the tree by zone, subnet, type, rack, vendor, tag… at no cost; it is grouped from data the app already holds
|
||||
2. **Write markdown**, save explicitly — nothing is ever saved behind your back. `/` inserts a freshly generated block (services, hardware, network, rack) from the device's current data
|
||||
3. **Link things**: `[[VLAN plan]]`, `[[device:nas-01]]`. Each document lists **Linked from** at the bottom, so you can see what points at it
|
||||
4. **History**: up to 50 versions per document. Read one, diff it against the current body, restore it — what it replaces is kept too
|
||||
5. **Search** the whole space, tag documents, and set `review_every: 6m` on the ones that rot; a document past its interval is badged as due
|
||||
|
||||
A device that changes after its document was written raises a **device data changed** banner, and **Regenerate** rebuilds the document from scratch when you want that. Old per-device **Notes** migrate into documents from a banner, non-destructively.
|
||||
|
||||
Full mode only — documents need the backend to store, index and search them.
|
||||
|
||||
> **Full documentation:** [docs/documentation.md](./docs/documentation.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -91,6 +135,14 @@ 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.
|
||||
|
||||
### MAC addresses (Docker)
|
||||
|
||||
The scan reports no MAC address when the backend runs on a Docker bridge
|
||||
network — ARP is layer 2, and from a bridge every LAN host sits behind the
|
||||
Docker gateway. It also means a DHCP device that changes IP comes back as a new
|
||||
inventory entry, since matching prefers the MAC. Fix and caveats:
|
||||
[INSTALLATION.md](./INSTALLATION.md#scanning-from-docker--mac-addresses).
|
||||
|
||||
### 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:
|
||||
@@ -309,8 +361,8 @@ Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io
|
||||
|
||||
| | Action |
|
||||
|---|---|
|
||||
| **Read** | List all nodes, edges, full canvas, pending devices, scan history |
|
||||
| **Write** | Add / update / delete nodes and edges, trigger a network scan, approve or hide discovered devices |
|
||||
| **Read** | List all nodes, edges, full canvas, zones, designs, the device inventory and scan history — and the rack canvases: racks, mounted gear, patched cables |
|
||||
| **Write** | Add / update / delete nodes, edges and zones, trigger a network scan, approve / hide / restore discovered devices, create and edit inventory entries, build racks and mount, move and patch the gear in them |
|
||||
|
||||
### Setup
|
||||
|
||||
@@ -386,6 +438,8 @@ Or add it manually to `~/.claude.json`:
|
||||
- *"Add a new LXC container named `pihole` at 192.168.1.5, connected to my switch."*
|
||||
- *"Trigger a network scan on 192.168.1.0/24 and show me the pending devices."*
|
||||
- *"Show me the full canvas topology."*
|
||||
- *"How much free U is left in the garage rack?"*
|
||||
- *"Mount the NAS in rack 1 and patch its first port to port 12 of the patch panel."*
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.config import app_base_path_of, settings
|
||||
from app.core.security import (
|
||||
clear_oidc_session_cookie,
|
||||
create_access_token,
|
||||
@@ -103,7 +103,10 @@ async def oidc_callback(request: Request) -> Response:
|
||||
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)
|
||||
# Back to the SPA — which is not necessarily at the root of the origin.
|
||||
response = RedirectResponse(
|
||||
url=app_base_path_of(settings.oidc_redirect_uri), status_code=status.HTTP_303_SEE_OTHER
|
||||
)
|
||||
set_oidc_session_cookie(response, session_token)
|
||||
return response
|
||||
|
||||
|
||||
@@ -12,6 +12,13 @@ from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
hydrated_node,
|
||||
link_facts,
|
||||
load_devices_for,
|
||||
node_columns,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -28,12 +35,15 @@ async def load_canvas(
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
# The device facts live on the inventory row now; hydrate them back into the
|
||||
# node so the wire shape the canvas consumes is unchanged.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=state.custom_style if state else None,
|
||||
@@ -74,16 +84,41 @@ async def save_canvas(
|
||||
|
||||
await db.flush()
|
||||
|
||||
# Upsert nodes
|
||||
# Upsert nodes, then route their device facts to the inventory row that owns
|
||||
# them — an edit made on this canvas is an edit to the device itself, and
|
||||
# every other canvas showing it follows.
|
||||
saved: list[tuple[Node, dict[str, Any], list[str] | None]] = []
|
||||
for node_data in body.nodes:
|
||||
db_node = await db.get(Node, node_data.id)
|
||||
payload = node_data.model_dump()
|
||||
payload["design_id"] = design_id
|
||||
facts = facts_from_payload(payload, label=node_data.label, node_type=node_data.type)
|
||||
columns = node_columns(payload)
|
||||
if db_node:
|
||||
for field, value in payload.items():
|
||||
for field, value in columns.items():
|
||||
setattr(db_node, field, value)
|
||||
else:
|
||||
db.add(Node(**payload))
|
||||
db_node = Node(**columns)
|
||||
db.add(db_node)
|
||||
saved.append((db_node, facts, node_data.changed_facts))
|
||||
|
||||
await db.flush()
|
||||
for node, facts, changed in saved:
|
||||
# The payload carries a full copy of each device, hydrated when this
|
||||
# canvas loaded. Routing all of it back would rewrite the row from a
|
||||
# stale snapshot — a save made for nothing but a moved node could revert
|
||||
# an edit meanwhile in the inventory modal. `changed_facts` (when the
|
||||
# client tracked it) plus the row diff narrow the write to this canvas'
|
||||
# actual edit.
|
||||
await link_facts(
|
||||
db,
|
||||
node,
|
||||
facts,
|
||||
overwrite_scalars=True,
|
||||
replace_lists=True,
|
||||
only_changed=True,
|
||||
changed_fields=changed,
|
||||
)
|
||||
|
||||
# Upsert edges
|
||||
for edge_data in body.edges:
|
||||
|
||||
@@ -6,8 +6,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node
|
||||
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
||||
from app.services.doc_links import unlink_documents
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -118,6 +119,56 @@ async def copy_design(
|
||||
)
|
||||
)
|
||||
|
||||
# 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(
|
||||
@@ -165,14 +216,26 @@ async def delete_design(
|
||||
count = (await db.execute(select(Design))).scalars().all()
|
||||
if len(count) <= 1:
|
||||
raise HTTPException(400, "Cannot delete the only design")
|
||||
# Delete associated canvas state, edges, nodes
|
||||
# Delete associated canvas state, rack rows, edges, nodes. SQLite does not
|
||||
# always enforce the ON DELETE CASCADE, so unwind by hand — cables before
|
||||
# devices before racks.
|
||||
cs = await db.get(CanvasState, design_id)
|
||||
if cs:
|
||||
await db.delete(cs)
|
||||
for cable in (await db.execute(select(RackCable).where(RackCable.design_id == design_id))).scalars().all():
|
||||
await db.delete(cable)
|
||||
for rack_device in (
|
||||
await db.execute(select(RackDevice).where(RackDevice.design_id == design_id))
|
||||
).scalars().all():
|
||||
await db.delete(rack_device)
|
||||
for rack in (await db.execute(select(Rack).where(Rack.design_id == design_id))).scalars().all():
|
||||
await db.delete(rack)
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
for e in edges:
|
||||
await db.delete(e)
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
# Foreign keys are off, so orphan the documents by hand before the rows go.
|
||||
await unlink_documents(db, node_ids=[n.id for n in nodes], design_ids=[design_id])
|
||||
for n in nodes:
|
||||
await db.delete(n)
|
||||
await db.delete(design)
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
"""Documents — the markdown documentation space.
|
||||
|
||||
A document is either a page or a folder in the Library tree, or it describes one
|
||||
thing elsewhere in the app: a Device Inventory row, a piece of canvas furniture
|
||||
(a zone, a group), or a whole canvas. The tree the user navigates is *not* stored
|
||||
here: device documents are pivoted client-side by zone, subnet, type and so on,
|
||||
because every one of those groupings is derivable from data the frontend already
|
||||
holds and re-pivoting must be instant.
|
||||
|
||||
The generated header is written once, at creation. Nothing in this module ever
|
||||
rewrites a body the user owns; `GET /blocks` hands the editor a freshly generated
|
||||
section on request, and `facts_snapshot` is what lets the UI say the device has
|
||||
moved on since the document was written.
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
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 Document, DocumentRevision, Edge, InventoryDevice, Node, Rack, RackDevice
|
||||
from app.schemas.documents import (
|
||||
BacklinkHit,
|
||||
CoverageResponse,
|
||||
DocumentCreate,
|
||||
DocumentResponse,
|
||||
DocumentSummary,
|
||||
DocumentUpdate,
|
||||
RevisionResponse,
|
||||
RevisionSummary,
|
||||
ScaffoldRequest,
|
||||
ScaffoldResponse,
|
||||
SearchHit,
|
||||
SearchResponse,
|
||||
)
|
||||
from app.services import doc_backlinks, doc_search
|
||||
from app.services.doc_export import ExportDoc, build_zip
|
||||
from app.services.doc_template import (
|
||||
BLOCKS,
|
||||
TEMPLATE_DEVICE,
|
||||
facts_snapshot,
|
||||
render_block,
|
||||
render_device_document,
|
||||
render_library_document,
|
||||
)
|
||||
from app.services.doc_tree import (
|
||||
REVISION_LIMIT,
|
||||
TREE_KINDS,
|
||||
is_ancestor,
|
||||
parse_frontmatter,
|
||||
subtree_ids,
|
||||
tags_from,
|
||||
unique_slug,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Node.type values that are canvas furniture. A zone or a group is documented
|
||||
# through its node; everything else is documented through its device.
|
||||
_FURNITURE_TYPES = {"group", "groupRect", "text"}
|
||||
|
||||
_INTERVAL = re.compile(r"^\s*(\d+)\s*([dwmy])\s*$", re.IGNORECASE)
|
||||
_INTERVAL_DAYS = {"d": 1, "w": 7, "m": 30, "y": 365}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
"""SQLite hands back naive datetimes; compare them as UTC."""
|
||||
if value is None:
|
||||
return None
|
||||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _parse_interval(raw: Any) -> timedelta | None:
|
||||
"""`review_every: 6m` → 180 days. Anything unparseable means "never due"."""
|
||||
match = _INTERVAL.match(str(raw or ""))
|
||||
if not match:
|
||||
return None
|
||||
return timedelta(days=int(match.group(1)) * _INTERVAL_DAYS[match.group(2).lower()])
|
||||
|
||||
|
||||
def _apply_body(doc: Document, body: str) -> None:
|
||||
"""Store a body and refresh the frontmatter/tags cache from it.
|
||||
|
||||
The body is the source of truth — it is what exports to disk — so the two
|
||||
JSON columns are only ever derived from it, never edited on their own.
|
||||
"""
|
||||
doc.body = body
|
||||
frontmatter = parse_frontmatter(body)
|
||||
doc.frontmatter = frontmatter
|
||||
doc.tags = tags_from(frontmatter)
|
||||
|
||||
|
||||
async def _adopt_frontmatter_title(db: AsyncSession, doc: Document) -> None:
|
||||
"""Follow the name the body gives, since the body is what the user edits.
|
||||
|
||||
`title:` in the frontmatter is the only place a document is named — there is
|
||||
no rename field anywhere in the UI — so a row that kept the title it was
|
||||
created with went stale the moment someone edited that line, and the tree
|
||||
went on showing the old name.
|
||||
"""
|
||||
front = doc.frontmatter if isinstance(doc.frontmatter, dict) else {}
|
||||
title = front.get("title")
|
||||
if not isinstance(title, str) or not title.strip() or title.strip() == doc.title:
|
||||
return
|
||||
doc.title = title.strip()
|
||||
doc.slug = await unique_slug(db, doc.title, parent_id=doc.parent_id, exclude_id=doc.id)
|
||||
|
||||
|
||||
async def _record_revision(db: AsyncSession, doc: Document, reason: str) -> None:
|
||||
"""Snapshot the current body, then prune the document's oldest history."""
|
||||
db.add(DocumentRevision(document_id=doc.id, title=doc.title, body=doc.body or "", reason=reason))
|
||||
await db.flush()
|
||||
stale = (
|
||||
await db.execute(
|
||||
select(DocumentRevision)
|
||||
.where(DocumentRevision.document_id == doc.id)
|
||||
.order_by(DocumentRevision.saved_at.desc(), DocumentRevision.id.desc())
|
||||
.offset(REVISION_LIMIT)
|
||||
)
|
||||
).scalars().all()
|
||||
for revision in stale:
|
||||
await db.delete(revision)
|
||||
|
||||
|
||||
async def _device_context(db: AsyncSession, device_id: str) -> dict[str, Any]:
|
||||
"""Zone, rack placement and canvas neighbours for a device.
|
||||
|
||||
All three are optional: a device may be on no canvas, in no rack and in no
|
||||
zone, and the template drops those sections rather than printing them empty.
|
||||
"""
|
||||
context: dict[str, Any] = {"zone_label": None, "rack": None, "connections": []}
|
||||
|
||||
node = (
|
||||
await db.execute(select(Node).where(Node.device_id == device_id).order_by(Node.created_at))
|
||||
).scalars().first()
|
||||
if node is not None:
|
||||
parent_id = node.parent_id
|
||||
seen: set[str] = set()
|
||||
while parent_id and parent_id not in seen:
|
||||
seen.add(parent_id)
|
||||
parent = await db.get(Node, parent_id)
|
||||
if parent is None:
|
||||
break
|
||||
if parent.type in _FURNITURE_TYPES:
|
||||
context["zone_label"] = parent.label
|
||||
break
|
||||
parent_id = parent.parent_id
|
||||
|
||||
edges = (
|
||||
await db.execute(select(Edge).where((Edge.source == node.id) | (Edge.target == node.id)))
|
||||
).scalars().all()
|
||||
peer_ids = [e.target if e.source == node.id else e.source for e in edges]
|
||||
if peer_ids:
|
||||
peers = (await db.execute(select(Node.label).where(Node.id.in_(peer_ids)))).scalars().all()
|
||||
context["connections"] = sorted({label for label in peers if label})
|
||||
|
||||
mount = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.device_id == device_id))
|
||||
).scalars().first()
|
||||
if mount is not None:
|
||||
rack = await db.get(Rack, mount.rack_id)
|
||||
context["rack"] = {
|
||||
"name": rack.name if rack else None,
|
||||
"u_start": mount.u_start,
|
||||
"u_height": mount.u_height,
|
||||
"col_span": mount.col_span,
|
||||
}
|
||||
return context
|
||||
|
||||
|
||||
async def _scaffold_body(db: AsyncSession, doc: Document, template_id: str | None) -> None:
|
||||
"""Generate this document's initial body. Called once, at creation."""
|
||||
if doc.device_id:
|
||||
device = await db.get(InventoryDevice, doc.device_id)
|
||||
if device is not None:
|
||||
context = await _device_context(db, doc.device_id)
|
||||
_apply_body(doc, render_device_document(device, **context))
|
||||
doc.facts_snapshot = facts_snapshot(device)
|
||||
doc.facts_synced_at = _now()
|
||||
doc.template_id = TEMPLATE_DEVICE
|
||||
return
|
||||
_apply_body(doc, render_library_document(template_id or "blank", doc.title))
|
||||
doc.template_id = template_id or "blank"
|
||||
|
||||
|
||||
def _has_drifted(doc: Document, device: InventoryDevice | None) -> bool:
|
||||
"""Whether the device has moved on since this document was snapshotted.
|
||||
|
||||
The comparison lives on the server rather than in the UI because
|
||||
`facts_snapshot` is the server's own shape — `label` and `type` are stored
|
||||
through their fallbacks and `properties` as a flat map — so nothing else
|
||||
can compare it to a device row correctly. Same rule as the coverage count.
|
||||
"""
|
||||
if not doc.device_id or not doc.facts_snapshot or device is None:
|
||||
return False
|
||||
return doc.facts_snapshot != facts_snapshot(device)
|
||||
|
||||
|
||||
async def _devices_for(db: AsyncSession, docs: list[Document]) -> dict[str, InventoryDevice]:
|
||||
"""The devices a batch of documents describes, in one query."""
|
||||
wanted = {d.device_id for d in docs if d.device_id and d.facts_snapshot}
|
||||
if not wanted:
|
||||
return {}
|
||||
rows = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(wanted)))
|
||||
).scalars().all()
|
||||
return {device.id: device for device in rows}
|
||||
|
||||
|
||||
def _summary(doc: Document, device: InventoryDevice | None) -> DocumentSummary:
|
||||
payload = DocumentSummary.model_validate(doc)
|
||||
payload.drifted = _has_drifted(doc, device)
|
||||
return payload
|
||||
|
||||
|
||||
async def _response(db: AsyncSession, doc: Document) -> DocumentResponse:
|
||||
"""One document, with the drift flag resolved against the live device."""
|
||||
payload = DocumentResponse.model_validate(doc)
|
||||
payload.drifted = _has_drifted(doc, await db.get(InventoryDevice, doc.device_id) if doc.device_id else None)
|
||||
return payload
|
||||
|
||||
|
||||
# ── list / read ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("", response_model=list[DocumentSummary])
|
||||
async def list_documents(
|
||||
kind: str | None = Query(None, description="Filter to one document kind"),
|
||||
parent_id: str | None = Query(None, description="Library children of this folder"),
|
||||
device_id: str | None = Query(None, description="The document describing this device"),
|
||||
tag: str | None = Query(None, description="Documents carrying this frontmatter tag"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[DocumentSummary]:
|
||||
query = select(Document)
|
||||
if kind:
|
||||
query = query.where(Document.kind == kind)
|
||||
if parent_id:
|
||||
query = query.where(Document.parent_id == parent_id)
|
||||
if device_id:
|
||||
query = query.where(Document.device_id == device_id)
|
||||
docs = (await db.execute(query.order_by(Document.sort_order, Document.title))).scalars().all()
|
||||
if tag:
|
||||
wanted = tag.lower()
|
||||
docs = [d for d in docs if any(str(t).lower() == wanted for t in (d.tags or []))]
|
||||
devices = await _devices_for(db, list(docs))
|
||||
return [_summary(d, devices.get(d.device_id or "")) for d in docs]
|
||||
|
||||
|
||||
@router.get("/coverage", response_model=CoverageResponse)
|
||||
async def coverage(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> CoverageResponse:
|
||||
"""How much of the homelab is actually written down."""
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
docs = (await db.execute(select(Document))).scalars().all()
|
||||
by_device = {d.device_id: d for d in docs if d.device_id}
|
||||
|
||||
header_only = drifted = overdue = 0
|
||||
now = _now()
|
||||
for device in devices:
|
||||
doc = by_device.get(device.id)
|
||||
if doc is None:
|
||||
continue
|
||||
# Never edited since it was generated: the template is all there is.
|
||||
if doc.edited_at is None and doc.reviewed_at is None:
|
||||
header_only += 1
|
||||
if doc.facts_snapshot and doc.facts_snapshot != facts_snapshot(device):
|
||||
drifted += 1
|
||||
interval = _parse_interval((doc.frontmatter or {}).get("review_every"))
|
||||
since = _aware(doc.reviewed_at) or _aware(doc.created_at)
|
||||
if interval and since and now - since > interval:
|
||||
overdue += 1
|
||||
|
||||
documented = sum(1 for device in devices if device.id in by_device)
|
||||
return CoverageResponse(
|
||||
devices=len(devices),
|
||||
documented=documented,
|
||||
header_only=header_only,
|
||||
missing=len(devices) - documented,
|
||||
drifted=drifted,
|
||||
overdue=overdue,
|
||||
notes_unmigrated=sum(
|
||||
1 for device in devices if (device.notes or "").strip() and device.id not in by_device
|
||||
),
|
||||
library_pages=sum(1 for d in docs if d.kind in TREE_KINDS),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search", response_model=SearchResponse)
|
||||
async def search_documents(
|
||||
q: str = Query(..., description="Full-text query"),
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> SearchResponse:
|
||||
engine, rows = await doc_search.search(db, q, limit)
|
||||
if not rows:
|
||||
return SearchResponse(engine=engine, hits=[])
|
||||
docs = {
|
||||
d.id: d
|
||||
for d in (
|
||||
await db.execute(select(Document).where(Document.id.in_([r["doc_id"] for r in rows])))
|
||||
).scalars().all()
|
||||
}
|
||||
hits = [
|
||||
SearchHit(
|
||||
doc_id=row["doc_id"],
|
||||
title=docs[row["doc_id"]].title,
|
||||
kind=docs[row["doc_id"]].kind,
|
||||
snippet=row["snippet"] or "",
|
||||
device_id=docs[row["doc_id"]].device_id,
|
||||
)
|
||||
for row in rows
|
||||
if row["doc_id"] in docs
|
||||
]
|
||||
return SearchResponse(engine=engine, hits=hits)
|
||||
|
||||
|
||||
@router.get("/blocks", response_model=dict)
|
||||
async def generated_block(
|
||||
block: str = Query(..., description=f"One of {sorted(BLOCKS)}"),
|
||||
device_id: str = Query(..., description="Device to read the facts from"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, str]:
|
||||
"""Render one generated section, for the editor's `/` insert menu.
|
||||
|
||||
This is the counterpart to generating the header only once: the user can
|
||||
always pull a fresh block in, but nothing pushes one at them.
|
||||
"""
|
||||
if block not in BLOCKS:
|
||||
raise HTTPException(400, f"block must be one of {sorted(BLOCKS)}")
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(404, "Device not found")
|
||||
context = await _device_context(db, device_id) if block in {"rack", "network"} else {}
|
||||
return {"block": block, "markdown": render_block(block, device, **context)}
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def export_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""Every document as a zip of `.md` files mirroring the tree.
|
||||
|
||||
Declared above `/{document_id}` so "export" is not read as an id.
|
||||
"""
|
||||
docs = (await db.execute(select(Document))).scalars().all()
|
||||
archive = build_zip(
|
||||
[
|
||||
ExportDoc(
|
||||
id=doc.id,
|
||||
kind=doc.kind,
|
||||
title=doc.title,
|
||||
slug=doc.slug,
|
||||
parent_id=doc.parent_id,
|
||||
body=doc.body or "",
|
||||
)
|
||||
for doc in docs
|
||||
]
|
||||
)
|
||||
filename = f"homelable-documentation-{_now():%Y%m%d}.zip"
|
||||
return Response(
|
||||
content=archive,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
# The browser reads the name from the header, and a cross-origin
|
||||
# fetch cannot see it unless it is exposed.
|
||||
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DocumentResponse:
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
return await _response(db, doc)
|
||||
|
||||
|
||||
@router.get("/{document_id}/revisions", response_model=list[RevisionSummary])
|
||||
async def list_revisions(
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[RevisionSummary]:
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
revisions = (
|
||||
await db.execute(
|
||||
select(DocumentRevision)
|
||||
.where(DocumentRevision.document_id == document_id)
|
||||
.order_by(DocumentRevision.saved_at.desc(), DocumentRevision.id.desc())
|
||||
)
|
||||
).scalars().all()
|
||||
return [
|
||||
RevisionSummary(
|
||||
id=r.id,
|
||||
document_id=r.document_id,
|
||||
title=r.title,
|
||||
reason=r.reason,
|
||||
saved_at=r.saved_at,
|
||||
size=len(r.body or ""),
|
||||
)
|
||||
for r in revisions
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{document_id}/backlinks", response_model=list[BacklinkHit])
|
||||
async def list_backlinks(
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[BacklinkHit]:
|
||||
"""The documents whose body links here.
|
||||
|
||||
Answered on the server because the browser only holds document *metadata* —
|
||||
the list endpoint carries no bodies, and loading every body to invert the
|
||||
links client-side would trade a small query for a large download on every
|
||||
open.
|
||||
"""
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
# Every document is loaded because every one is a possible *target* of a
|
||||
# link — a bare `[[VLAN plan]]` resolves by title. Only the bodies carrying
|
||||
# `[[` are walked as sources, and the inventory is fetched only when a
|
||||
# `[[device:…]]` is actually in play.
|
||||
docs = list(
|
||||
(
|
||||
await db.execute(select(Document).order_by(Document.sort_order, Document.title))
|
||||
).scalars().all()
|
||||
)
|
||||
devices = (
|
||||
list((await db.execute(select(InventoryDevice))).scalars().all())
|
||||
if doc_backlinks.has_device_link(docs)
|
||||
else []
|
||||
)
|
||||
|
||||
titles = {d.id: d for d in docs}
|
||||
return [
|
||||
BacklinkHit(
|
||||
doc_id=hit.doc_id,
|
||||
title=titles[hit.doc_id].title,
|
||||
kind=titles[hit.doc_id].kind,
|
||||
device_id=titles[hit.doc_id].device_id,
|
||||
label=hit.label,
|
||||
context=hit.context,
|
||||
count=hit.count,
|
||||
)
|
||||
for hit in doc_backlinks.backlinks_for(document_id, docs, devices)
|
||||
if hit.doc_id in titles
|
||||
]
|
||||
|
||||
|
||||
@router.get("/revisions/{revision_id}", response_model=RevisionResponse)
|
||||
async def get_revision(
|
||||
revision_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> RevisionResponse:
|
||||
revision = await db.get(DocumentRevision, revision_id)
|
||||
if not revision:
|
||||
raise HTTPException(404, "Revision not found")
|
||||
return RevisionResponse(
|
||||
id=revision.id,
|
||||
document_id=revision.document_id,
|
||||
title=revision.title,
|
||||
reason=revision.reason,
|
||||
saved_at=revision.saved_at,
|
||||
size=len(revision.body or ""),
|
||||
body=revision.body or "",
|
||||
)
|
||||
|
||||
|
||||
# ── write ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("", response_model=DocumentResponse, status_code=201)
|
||||
async def create_document(
|
||||
body: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DocumentResponse:
|
||||
links = [body.device_id, body.node_id, body.design_id]
|
||||
if sum(1 for link in links if link) > 1:
|
||||
raise HTTPException(400, "A document describes at most one device, node or design")
|
||||
if body.kind in TREE_KINDS and any(links):
|
||||
raise HTTPException(400, "A page or folder cannot also describe a device, node or design")
|
||||
if body.kind not in TREE_KINDS and not any(links):
|
||||
raise HTTPException(400, f"A '{body.kind}' document must name what it describes")
|
||||
if body.parent_id and body.kind not in TREE_KINDS:
|
||||
raise HTTPException(400, "Only pages and folders live in the Library tree")
|
||||
|
||||
for link, model, what in (
|
||||
(body.device_id, InventoryDevice, "Device"),
|
||||
(body.node_id, Node, "Node"),
|
||||
):
|
||||
if link and not await db.get(model, link):
|
||||
raise HTTPException(404, f"{what} not found")
|
||||
if body.parent_id and not await db.get(Document, body.parent_id):
|
||||
raise HTTPException(404, "Parent folder not found")
|
||||
|
||||
for column, value in (
|
||||
(Document.device_id, body.device_id),
|
||||
(Document.node_id, body.node_id),
|
||||
(Document.design_id, body.design_id),
|
||||
):
|
||||
if value and (await db.execute(select(Document).where(column == value))).scalars().first():
|
||||
raise HTTPException(409, "That already has a document")
|
||||
|
||||
doc = Document(
|
||||
kind=body.kind,
|
||||
title=body.title,
|
||||
slug=await unique_slug(db, body.title, parent_id=body.parent_id),
|
||||
icon=body.icon,
|
||||
parent_id=body.parent_id,
|
||||
device_id=body.device_id,
|
||||
node_id=body.node_id,
|
||||
design_id=body.design_id,
|
||||
)
|
||||
if body.body is not None:
|
||||
_apply_body(doc, body.body)
|
||||
doc.template_id = body.template_id
|
||||
else:
|
||||
await _scaffold_body(db, doc, body.template_id)
|
||||
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
await doc_search.index_document(db, doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
return await _response(db, doc)
|
||||
|
||||
|
||||
@router.patch("/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
document_id: str,
|
||||
body: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DocumentResponse:
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
sent = body.model_dump(exclude_unset=True)
|
||||
|
||||
if "parent_id" in sent:
|
||||
parent_id = sent["parent_id"]
|
||||
if doc.kind not in TREE_KINDS:
|
||||
raise HTTPException(400, "Only pages and folders live in the Library tree")
|
||||
if parent_id:
|
||||
parent = await db.get(Document, parent_id)
|
||||
if not parent:
|
||||
raise HTTPException(404, "Parent folder not found")
|
||||
if parent.kind != "folder":
|
||||
raise HTTPException(400, "A document can only be filed under a folder")
|
||||
if await is_ancestor(db, document_id, parent_id):
|
||||
raise HTTPException(400, "A folder cannot be moved inside itself")
|
||||
doc.parent_id = parent_id
|
||||
doc.slug = await unique_slug(db, doc.title, parent_id=parent_id, exclude_id=doc.id)
|
||||
|
||||
if "body" in sent and sent["body"] is not None and sent["body"] != doc.body:
|
||||
await _record_revision(db, doc, "edit")
|
||||
_apply_body(doc, sent["body"])
|
||||
doc.edited_at = _now()
|
||||
# An explicit title in the same request still wins: it is applied below.
|
||||
await _adopt_frontmatter_title(db, doc)
|
||||
|
||||
if "title" in sent and sent["title"]:
|
||||
doc.title = sent["title"]
|
||||
doc.slug = await unique_slug(db, doc.title, parent_id=doc.parent_id, exclude_id=doc.id)
|
||||
if "icon" in sent:
|
||||
doc.icon = sent["icon"]
|
||||
if "sort_order" in sent and sent["sort_order"] is not None:
|
||||
doc.sort_order = sent["sort_order"]
|
||||
if "starred" in sent and sent["starred"] is not None:
|
||||
doc.starred = sent["starred"]
|
||||
if sent.get("reviewed"):
|
||||
doc.reviewed_at = _now()
|
||||
if sent.get("resync_facts") and doc.device_id:
|
||||
device = await db.get(InventoryDevice, doc.device_id)
|
||||
if device is not None:
|
||||
doc.facts_snapshot = facts_snapshot(device)
|
||||
doc.facts_synced_at = _now()
|
||||
|
||||
await db.flush()
|
||||
await doc_search.index_document(db, doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
return await _response(db, doc)
|
||||
|
||||
|
||||
@router.post("/{document_id}/revisions/{revision_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_revision(
|
||||
document_id: str,
|
||||
revision_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DocumentResponse:
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
revision = await db.get(DocumentRevision, revision_id)
|
||||
if not revision or revision.document_id != document_id:
|
||||
raise HTTPException(404, "Revision not found")
|
||||
# The body being replaced becomes history too, so a restore is undoable.
|
||||
await _record_revision(db, doc, "restore")
|
||||
_apply_body(doc, revision.body or "")
|
||||
# A restored body brings its own title back with it.
|
||||
await _adopt_frontmatter_title(db, doc)
|
||||
await db.flush()
|
||||
await doc_search.index_document(db, doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
return await _response(db, doc)
|
||||
|
||||
|
||||
@router.post("/{document_id}/regenerate", response_model=DocumentResponse)
|
||||
async def regenerate_document(
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> DocumentResponse:
|
||||
"""Throw the body away and scaffold it again from what the database holds.
|
||||
|
||||
The one place that overwrites a body the user owns, so it is only ever
|
||||
reached from an explicit confirmation. The replaced body is snapshotted
|
||||
first, which makes the whole thing undoable from the history list.
|
||||
"""
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
if doc.kind == "folder":
|
||||
raise HTTPException(400, "A folder has no body to regenerate")
|
||||
if doc.device_id and not await db.get(InventoryDevice, doc.device_id):
|
||||
raise HTTPException(404, "Device not found")
|
||||
|
||||
await _record_revision(db, doc, "regenerate")
|
||||
await _scaffold_body(db, doc, doc.template_id)
|
||||
# Back to a freshly generated document: nothing of the user's is left in it.
|
||||
doc.edited_at = None
|
||||
await db.flush()
|
||||
await doc_search.index_document(db, doc)
|
||||
await db.commit()
|
||||
await db.refresh(doc)
|
||||
return await _response(db, doc)
|
||||
|
||||
|
||||
@router.post("/scaffold", response_model=ScaffoldResponse)
|
||||
async def scaffold_documents(
|
||||
body: ScaffoldRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScaffoldResponse:
|
||||
"""Create the device documents that do not exist yet.
|
||||
|
||||
This is the migration off `device_inventory.notes`: the old text is appended
|
||||
verbatim under `## Notes` and the column is left untouched, so nothing is
|
||||
lost and the move is repeatable.
|
||||
"""
|
||||
query = select(InventoryDevice).where(InventoryDevice.status != "hidden")
|
||||
if body.device_ids:
|
||||
query = query.where(InventoryDevice.id.in_(body.device_ids))
|
||||
devices = (await db.execute(query.order_by(InventoryDevice.discovered_at))).scalars().all()
|
||||
existing = {
|
||||
doc_id
|
||||
for doc_id in (
|
||||
await db.execute(select(Document.device_id).where(Document.device_id.is_not(None)))
|
||||
).scalars().all()
|
||||
}
|
||||
|
||||
created: list[Document] = []
|
||||
skipped = 0
|
||||
for device in devices:
|
||||
if device.id in existing:
|
||||
skipped += 1
|
||||
continue
|
||||
if body.only_with_notes and not (device.notes or "").strip():
|
||||
skipped += 1
|
||||
continue
|
||||
context = await _device_context(db, device.id)
|
||||
doc = Document(
|
||||
kind="device",
|
||||
title=device.label or device.friendly_name or device.hostname or device.ip or "device",
|
||||
slug="",
|
||||
device_id=device.id,
|
||||
template_id=TEMPLATE_DEVICE,
|
||||
facts_snapshot=facts_snapshot(device),
|
||||
facts_synced_at=_now(),
|
||||
)
|
||||
doc.slug = await unique_slug(db, doc.title, parent_id=None)
|
||||
_apply_body(doc, render_device_document(device, **context))
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
db.add(
|
||||
DocumentRevision(
|
||||
document_id=doc.id,
|
||||
title=doc.title,
|
||||
body=doc.body,
|
||||
reason="migrate" if (device.notes or "").strip() else "scaffold",
|
||||
)
|
||||
)
|
||||
await doc_search.index_document(db, doc)
|
||||
created.append(doc)
|
||||
|
||||
await db.commit()
|
||||
for doc in created:
|
||||
await db.refresh(doc)
|
||||
return ScaffoldResponse(created=[DocumentSummary.model_validate(d) for d in created], skipped=skipped)
|
||||
|
||||
|
||||
@router.delete("/{document_id}", status_code=204)
|
||||
async def delete_document(
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> None:
|
||||
doc = await db.get(Document, document_id)
|
||||
if not doc:
|
||||
raise HTTPException(404, "Document not found")
|
||||
# SQLite does not always enforce the ON DELETE CASCADE, so walk the subtree
|
||||
# by hand — a folder takes its children with it.
|
||||
for doc_id in reversed(await subtree_ids(db, document_id)):
|
||||
target = await db.get(Document, doc_id)
|
||||
if target is None:
|
||||
continue
|
||||
for revision in (
|
||||
await db.execute(select(DocumentRevision).where(DocumentRevision.document_id == doc_id))
|
||||
).scalars().all():
|
||||
await db.delete(revision)
|
||||
await doc_search.unindex_document(db, doc_id)
|
||||
await db.delete(target)
|
||||
await db.commit()
|
||||
@@ -6,6 +6,7 @@ from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Design, Edge, Node
|
||||
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
||||
from app.schemas.utils import SIDES, clamp_handles, handle_count_field, parse_side_handle
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -55,6 +56,74 @@ async def _auto_handles(
|
||||
return "top", "bottom-t"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection-point guards
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _side_count(node: Node, side: str) -> int:
|
||||
"""A node's clamped connection-point count for one side."""
|
||||
return clamp_handles(side, getattr(node, handle_count_field(side), None))
|
||||
|
||||
|
||||
async def _reject_missing_handle(db: AsyncSession, node_id: str, handle: str | None, role: str) -> None:
|
||||
"""422 when an endpoint names a connection point its node does not have.
|
||||
|
||||
React Flow draws nothing for an edge whose handle ID resolves to no element,
|
||||
so such an edge would persist and be invisible on the canvas with no error
|
||||
anywhere — the same failure `_remap_shrunk_handles` prevents when a count is
|
||||
lowered. Callers that supply no handle are auto-assigned one instead and
|
||||
never reach this.
|
||||
"""
|
||||
if handle is None:
|
||||
return
|
||||
parsed = parse_side_handle(handle)
|
||||
if parsed is None:
|
||||
# Not a per-side handle ('cluster-right' and friends): its own renderer
|
||||
# owns it, and no count here describes it.
|
||||
return
|
||||
side, idx = parsed
|
||||
node = await db.get(Node, node_id)
|
||||
if node is None:
|
||||
# An unknown node is not this guard's error to report; the edge routes
|
||||
# have never checked that either endpoint exists.
|
||||
return
|
||||
count = _side_count(node, side)
|
||||
if 0 <= idx < count:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"{role}_handle '{handle}' does not exist: node {node_id} has {count} "
|
||||
f"connection point(s) on its {side} side."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _existing_handle(db: AsyncSession, node_id: str, handle: str) -> str:
|
||||
"""Keep an auto-assigned handle, or swap it for one the node actually has.
|
||||
|
||||
The auto-assignment below always names a top/bottom slot 0, which a node that
|
||||
opted out of those sides no longer draws. The server picked it, not the
|
||||
caller, so this corrects it silently rather than raising. A node with no
|
||||
connection point at all keeps the original: there is nothing better to use,
|
||||
and refusing to create the edge would lose more than it saves.
|
||||
"""
|
||||
node = await db.get(Node, node_id)
|
||||
if node is None:
|
||||
return handle
|
||||
parsed = parse_side_handle(handle)
|
||||
if parsed is not None:
|
||||
side, idx = parsed
|
||||
if 0 <= idx < _side_count(node, side):
|
||||
return handle
|
||||
suffix = "-t" if handle.endswith("-t") else ""
|
||||
for side in SIDES:
|
||||
if _side_count(node, side) > 0:
|
||||
return f"{side}{suffix}"
|
||||
return handle
|
||||
|
||||
|
||||
@router.get("", response_model=list[EdgeResponse])
|
||||
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
|
||||
result = await db.execute(select(Edge))
|
||||
@@ -75,12 +144,15 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s
|
||||
# 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.
|
||||
await _reject_missing_handle(db, data["source"], data.get("source_handle"), "source")
|
||||
await _reject_missing_handle(db, data["target"], data.get("target_handle"), "target")
|
||||
|
||||
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
|
||||
data["source_handle"] = await _existing_handle(db, data["source"], auto_src)
|
||||
if data.get("target_handle") is None:
|
||||
data["target_handle"] = auto_tgt
|
||||
data["target_handle"] = await _existing_handle(db, data["target"], auto_tgt)
|
||||
|
||||
edge = Edge(**data)
|
||||
db.add(edge)
|
||||
@@ -105,7 +177,10 @@ async def update_edge(
|
||||
edge = await db.get(Edge, edge_id)
|
||||
if not edge:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Edge not found")
|
||||
for field, value in body.model_dump(exclude_unset=True).items():
|
||||
sent = body.model_dump(exclude_unset=True)
|
||||
await _reject_missing_handle(db, edge.source, sent.get("source_handle"), "source")
|
||||
await _reject_missing_handle(db, edge.target, sent.get("target_handle"), "target")
|
||||
for field, value in sent.items():
|
||||
setattr(edge, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(edge)
|
||||
|
||||
@@ -13,6 +13,7 @@ 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()
|
||||
|
||||
@@ -59,13 +60,15 @@ async def liveview_canvas(
|
||||
if design_id is None:
|
||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
||||
|
||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
||||
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
|
||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
||||
state = await db.get(CanvasState, design_id)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
# Device facts come off the inventory row, same as the editable canvas.
|
||||
devices = await load_devices_for(db, nodes)
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
|
||||
+106
-12
@@ -1,15 +1,74 @@
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, 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 Design, Node
|
||||
from app.db.models import Design, Edge, InventoryDevice, Node
|
||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||
from app.schemas.utils import (
|
||||
SIDES,
|
||||
handle_count_field,
|
||||
handle_id,
|
||||
removed_handle_ids,
|
||||
side_default,
|
||||
)
|
||||
from app.services.doc_links import unlink_documents
|
||||
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()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection-point helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _remap_shrunk_handles(db: AsyncSession, node: Node, sent: dict[str, Any]) -> None:
|
||||
"""Move this node's edges off the connection points a shrink just deleted.
|
||||
|
||||
The canvas store does the same remap client-side (canvasStore.updateNode), so
|
||||
the UI never hits this; an API client — the MCP write tools — lowers a handle
|
||||
count without it, and React Flow silently drops an edge whose handle no longer
|
||||
exists. Removed handles fall back to the side's slot-0 ID, or to 'bottom' when
|
||||
the side went to zero and has no slot 0 left.
|
||||
|
||||
Call before the new counts are written to the node.
|
||||
"""
|
||||
fallbacks: dict[str, str] = {}
|
||||
for side in SIDES:
|
||||
field = handle_count_field(side)
|
||||
new_count = sent.get(field)
|
||||
if new_count is None:
|
||||
continue
|
||||
raw_old = getattr(node, field, None)
|
||||
old_count = raw_old if isinstance(raw_old, int) else side_default(side)
|
||||
if new_count >= old_count:
|
||||
continue
|
||||
fallback = 'bottom' if new_count == 0 else handle_id(side, 0)
|
||||
for hid in removed_handle_ids(side, old_count, new_count):
|
||||
fallbacks[hid] = fallback
|
||||
|
||||
if not fallbacks:
|
||||
return
|
||||
|
||||
result = await db.execute(select(Edge).where(or_(Edge.source == node.id, Edge.target == node.id)))
|
||||
for edge in result.scalars().all():
|
||||
if edge.source == node.id and edge.source_handle in fallbacks:
|
||||
edge.source_handle = fallbacks[edge.source_handle]
|
||||
if edge.target == node.id and edge.target_handle in fallbacks:
|
||||
edge.target_handle = fallbacks[edge.target_handle]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-positioning helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -59,15 +118,17 @@ 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[Node]:
|
||||
) -> list[Any]:
|
||||
query = select(Node)
|
||||
if label:
|
||||
query = query.where(Node.label.ilike(f"%{label}%"))
|
||||
return list((await db.execute(query)).scalars().all())
|
||||
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:
|
||||
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)
|
||||
@@ -103,33 +164,64 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
|
||||
if data["pos_y"] is None:
|
||||
data["pos_y"] = 0.0
|
||||
|
||||
node = Node(**data)
|
||||
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)
|
||||
# A node can never be its own parent: the canvas' parent walks assume an
|
||||
# acyclic tree and a self-parent row freezes the node on screen (#370).
|
||||
# Dropped rather than rejected so the rest of the edit still lands.
|
||||
if sent.get("parent_id") == node_id:
|
||||
sent.pop("parent_id")
|
||||
# Before the new counts land: the old ones are what says which handles go away.
|
||||
await _remap_shrunk_handles(db, node, sent)
|
||||
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)
|
||||
@@ -137,5 +229,7 @@ async def delete_node(node_id: str, db: AsyncSession = Depends(get_db), _: str =
|
||||
node = await db.get(Node, node_id)
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
# Foreign keys are off, so the schema's ON DELETE SET NULL does not fire.
|
||||
await unlink_documents(db, node_ids=[node_id])
|
||||
await db.delete(node)
|
||||
await db.commit()
|
||||
|
||||
@@ -21,7 +21,7 @@ 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 Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.proxmox import (
|
||||
ProxmoxConfig,
|
||||
ProxmoxConnectionRequest,
|
||||
@@ -33,9 +33,11 @@ from app.schemas.proxmox import (
|
||||
ProxmoxTestConnectionResponse,
|
||||
)
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.services.device_merge import reconcile_duplicates
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import attach_device_ids
|
||||
from app.services.mac_utils import normalize_mac
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.proxmox_service import (
|
||||
build_proxmox_cluster_links,
|
||||
build_proxmox_properties,
|
||||
@@ -51,6 +53,9 @@ router = APIRouter()
|
||||
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
|
||||
_PROXMOX_GUEST_SOURCE = "proxmox"
|
||||
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"
|
||||
# Every imported guest/host carries a synthetic ieee under this prefix
|
||||
# (``pve-node-{host}`` / ``pve-{host}-{vmid}``), built in proxmox_service.
|
||||
_PVE_IEEE_PREFIX = "pve-"
|
||||
|
||||
|
||||
def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
|
||||
@@ -88,6 +93,7 @@ async def test_connection_endpoint(
|
||||
@router.post("/import", response_model=ProxmoxImportResponse)
|
||||
async def import_proxmox(
|
||||
payload: ProxmoxConnectionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ProxmoxImportResponse:
|
||||
"""Fetch the inventory and return nodes + edges ready for canvas drop."""
|
||||
@@ -108,6 +114,12 @@ async def import_proxmox(
|
||||
logger.exception("Unexpected error during Proxmox import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc
|
||||
|
||||
# A canvas import is an import: the guests land in the Device Inventory too,
|
||||
# and each node carries the id of the row it draws so the canvas save links
|
||||
# to it instead of minting a second row for the same guest.
|
||||
await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||
nodes_raw = await attach_device_ids(db, nodes_raw)
|
||||
|
||||
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))
|
||||
@@ -245,16 +257,18 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ProxmoxImportPendingResponse:
|
||||
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
|
||||
"""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-...``).
|
||||
Two-tier identity (order matters) — see ``_find_pending``:
|
||||
1. Match by the synthetic ``ieee_address`` (``pve-{host}-{vmid}``), the
|
||||
only key that identifies a guest.
|
||||
2. Else match an existing pending row by MAC, then IP, to merge into a
|
||||
device a scan found first — never duplicate. Rows already claimed by
|
||||
another Proxmox guest are excluded.
|
||||
|
||||
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
|
||||
"""
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
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}
|
||||
@@ -270,51 +284,50 @@ async def _persist_pending_import(
|
||||
mac = normalize_mac(n.get("mac"))
|
||||
props = build_proxmox_properties(n)
|
||||
|
||||
# 1) Already on a canvas? 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 node). Refresh in place: merge properties, adopt
|
||||
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
|
||||
# Do NOT stomp user-set type/status.
|
||||
node_filter = [Node.ieee_address == ieee]
|
||||
if ip:
|
||||
node_filter.append(Node.ip == ip)
|
||||
if mac:
|
||||
node_filter.append(Node.mac == mac)
|
||||
existing_nodes = (
|
||||
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
|
||||
).scalars().all()
|
||||
|
||||
if existing_nodes:
|
||||
for en in existing_nodes:
|
||||
en.properties = merge_proxmox_properties(en.properties, props)
|
||||
if not en.ieee_address:
|
||||
en.ieee_address = ieee
|
||||
if ip and not en.ip:
|
||||
en.ip = ip
|
||||
if mac and not en.mac:
|
||||
en.mac = mac
|
||||
en.hostname = en.hostname or n.get("hostname")
|
||||
en.cpu_count = en.cpu_count or n.get("cpu_count")
|
||||
en.ram_gb = en.ram_gb or n.get("ram_gb")
|
||||
en.disk_gb = en.disk_gb or n.get("disk_gb")
|
||||
# A cluster host needs one left + one right handle for the
|
||||
# cluster edge endpoints (both default to 0).
|
||||
if ieee in cluster_members:
|
||||
en.left_handles = max(en.left_handles or 0, 1)
|
||||
en.right_handles = max(en.right_handles or 0, 1)
|
||||
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# 2) Not on canvas — upsert the pending inventory row.
|
||||
# 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:
|
||||
_refresh_pending(pending, ieee, ip, mac, n, props)
|
||||
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)
|
||||
|
||||
# Fold in any row this import just proved to be the same device: a guest
|
||||
# matched here by its synthetic ieee can share a MAC or an IP with a row an
|
||||
# earlier scan (or an older canvas node) minted before either side carried
|
||||
# the address that would have matched them.
|
||||
await reconcile_duplicates(db)
|
||||
|
||||
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
||||
await db.commit()
|
||||
|
||||
@@ -328,15 +341,71 @@ async def _persist_pending_import(
|
||||
|
||||
async def _find_pending(
|
||||
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
|
||||
) -> PendingDevice | None:
|
||||
filters = [PendingDevice.ieee_address == ieee]
|
||||
if ip:
|
||||
filters.append(PendingDevice.ip == ip)
|
||||
if mac:
|
||||
filters.append(PendingDevice.mac == mac)
|
||||
return (
|
||||
await db.execute(select(PendingDevice).where(or_(*filters)))
|
||||
) -> InventoryDevice | None:
|
||||
"""The inventory row this guest *is*, in strict precedence order.
|
||||
|
||||
1. The synthetic ``ieee_address`` (``pve-{host}-{vmid}``). Unique per guest
|
||||
and the only key that identifies one.
|
||||
2. Else the NIC MAC, else the IP — merging into a row an IP/ARP scan found
|
||||
first, or into this same guest under its previous host name.
|
||||
|
||||
An IP is not an identity: a duplicated static lease, a re-used DHCP address,
|
||||
two guests behind one NAT, or a container-bridge address several guests
|
||||
report to the agent all make one address describe several machines. So the
|
||||
IP fallback skips any row already claimed by a Proxmox guest — matching one
|
||||
used to overwrite that guest's hostname, VMID and specs, and
|
||||
non-deterministically, since one flat OR with no ordering returned whichever
|
||||
row the database yielded first. Oldest row wins, so a re-import is stable.
|
||||
|
||||
A MAC *is* an identity, so its fallback matches a claimed row too. It has to:
|
||||
the synthetic ieee embeds the host name, and a live migration or a node
|
||||
rename changes it for what is the same guest (``pve-pve1-802`` →
|
||||
``pve-pve2-802``). Excluding claimed rows there would file the migrated
|
||||
guest as a new device and orphan its old row. See ``_adopted_ieee``.
|
||||
"""
|
||||
exact = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalars().first()
|
||||
if exact is not None:
|
||||
return exact
|
||||
|
||||
unclaimed = or_(
|
||||
InventoryDevice.ieee_address.is_(None),
|
||||
~InventoryDevice.ieee_address.startswith(_PVE_IEEE_PREFIX),
|
||||
)
|
||||
for column, value, claimed_ok in (
|
||||
(InventoryDevice.mac, mac, True),
|
||||
(InventoryDevice.ip, ip, False),
|
||||
):
|
||||
if not value:
|
||||
continue
|
||||
where = [column == value] if claimed_ok else [column == value, unclaimed]
|
||||
row = (
|
||||
await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(*where)
|
||||
.order_by(InventoryDevice.discovered_at, InventoryDevice.id)
|
||||
)
|
||||
).scalars().first()
|
||||
if row is not None:
|
||||
return row
|
||||
return None
|
||||
|
||||
|
||||
def _adopted_ieee(current: str | None, ieee: str) -> str:
|
||||
"""The ``ieee_address`` a row should carry once this guest merges into it.
|
||||
|
||||
Re-point a row that already belongs to a Proxmox guest: matched by NIC MAC,
|
||||
it is this same guest under a stale host name (migrated, or the node was
|
||||
renamed). Links are rebuilt keyed on the ieee, so a row left on the old one
|
||||
stops resolving as a host→guest endpoint. A mesh ieee is a real hardware
|
||||
address and is never overwritten.
|
||||
"""
|
||||
if not current or current.startswith(_PVE_IEEE_PREFIX):
|
||||
return ieee
|
||||
return current
|
||||
|
||||
|
||||
def _new_pending(
|
||||
@@ -346,8 +415,8 @@ def _new_pending(
|
||||
n: dict[str, Any],
|
||||
props: list[dict[str, Any]],
|
||||
status: str,
|
||||
) -> PendingDevice:
|
||||
return PendingDevice(
|
||||
) -> InventoryDevice:
|
||||
return InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
ip=ip,
|
||||
mac=mac,
|
||||
@@ -357,13 +426,18 @@ def _new_pending(
|
||||
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: PendingDevice) -> list[str]:
|
||||
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
|
||||
@@ -374,14 +448,14 @@ def _sources_after_merge(row: PendingDevice) -> list[str]:
|
||||
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-")
|
||||
was_scanned = not (row.ieee_address or "").startswith(_PVE_IEEE_PREFIX)
|
||||
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: PendingDevice,
|
||||
pending: InventoryDevice,
|
||||
ieee: str,
|
||||
ip: str | None,
|
||||
mac: str | None,
|
||||
@@ -390,7 +464,7 @@ def _refresh_pending(
|
||||
) -> 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.ieee_address = _adopted_ieee(pending.ieee_address, ieee)
|
||||
pending.ip = ip or pending.ip
|
||||
pending.mac = pending.mac or mac
|
||||
pending.hostname = n.get("hostname") or pending.hostname
|
||||
@@ -422,7 +496,7 @@ async def _ensure_inventory_row(
|
||||
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.ieee_address = _adopted_ieee(inv.ieee_address, ieee)
|
||||
inv.ip = ip or inv.ip
|
||||
inv.mac = inv.mac or mac
|
||||
inv.hostname = n.get("hostname") or inv.hostname
|
||||
@@ -441,8 +515,8 @@ async def _replace_links(
|
||||
(``proxmox_cluster`` → 'cluster' edges).
|
||||
"""
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(
|
||||
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
||||
sa_delete(InventoryDeviceLink).where(
|
||||
InventoryDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
||||
)
|
||||
)
|
||||
recorded = 0
|
||||
@@ -453,7 +527,7 @@ async def _replace_links(
|
||||
if not src or not tgt or (src, tgt) in seen:
|
||||
return
|
||||
seen.add((src, tgt))
|
||||
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
||||
db.add(InventoryDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
||||
recorded += 1
|
||||
|
||||
for e in edges_raw:
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
from collections.abc import Sequence
|
||||
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 (
|
||||
RACK_COLUMNS,
|
||||
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 []
|
||||
|
||||
|
||||
def _rack_model(device: InventoryDevice) -> dict[str, Any] | None:
|
||||
"""The rack modelisation the inventory row owns, or None when never modelled.
|
||||
|
||||
`rack_faceplate_id` is the flag: a device that has never been mounted (or was
|
||||
mounted before this became inventory-owned) carries NULL, and the mount's own
|
||||
denormalized copy then stands unchanged.
|
||||
"""
|
||||
if not device.rack_faceplate_id:
|
||||
return None
|
||||
return {
|
||||
"faceplate_id": device.rack_faceplate_id,
|
||||
"u_height": device.rack_u_height,
|
||||
"col_span": device.rack_col_span,
|
||||
"color": device.rack_color,
|
||||
"ports": [p for p in (device.rack_ports or []) if isinstance(p, dict) and p.get("id")],
|
||||
}
|
||||
|
||||
|
||||
# A mount's footprint in the rack grid: (u_start, u_height, col_start, col_span).
|
||||
_Box = tuple[int, int, int, int]
|
||||
|
||||
|
||||
def _height_fits(u_start: int, u_height: int | None, rack_u_height: int | None) -> bool:
|
||||
"""Whether a mount of that height, starting there, stays under the top rail."""
|
||||
if not u_height:
|
||||
return False
|
||||
return rack_u_height is None or u_start + u_height - 1 <= rack_u_height
|
||||
|
||||
|
||||
def _span_fits(col_start: int, col_span: int | None) -> bool:
|
||||
"""Whether a mount of that width, starting there, stays inside the grid."""
|
||||
if not col_span:
|
||||
return False
|
||||
return col_start + col_span <= RACK_COLUMNS
|
||||
|
||||
|
||||
def _overlaps(box: _Box, others: Sequence[_Box]) -> bool:
|
||||
"""Whether a footprint (u_start, u_height, col_start, col_span) hits any other."""
|
||||
u_start, u_height, col_start, col_span = box
|
||||
return any(
|
||||
u_start < o_u + o_uh
|
||||
and o_u < u_start + u_height
|
||||
and col_start < o_c + o_cs
|
||||
and o_c < col_start + col_span
|
||||
for o_u, o_uh, o_c, o_cs in others
|
||||
)
|
||||
|
||||
|
||||
def _with_model(
|
||||
device: RackDevice,
|
||||
model: dict[str, Any] | None,
|
||||
rack_u_height: int | None,
|
||||
neighbours: Sequence[_Box] = (),
|
||||
) -> RackDeviceResponse:
|
||||
"""Overlay the inventory's rack modelisation onto a mount for the response.
|
||||
|
||||
Geometry is global, but a rack is not: a device grown to 4U in one rack may no
|
||||
longer fit where it sits in another. Height and width are therefore applied
|
||||
only when they still fit at the mount's own `u_start` / `col_start` *and* the
|
||||
bigger footprint lands on no neighbour — the placement rule the canvas itself
|
||||
enforces, and one a plain rail check misses: a 4U model overlaid on a mount
|
||||
with a device one U above it would draw two plates on the same U, and save
|
||||
back in that state. The plate, its colour and its ports always are, since
|
||||
none of them can overflow anything.
|
||||
"""
|
||||
row = RackDeviceResponse.model_validate(device)
|
||||
if model is None:
|
||||
return row
|
||||
patch: dict[str, Any] = {
|
||||
"faceplate_id": model["faceplate_id"],
|
||||
"ports": model["ports"],
|
||||
"color": model["color"],
|
||||
}
|
||||
u_height = row.u_height
|
||||
if _height_fits(row.u_start, model["u_height"], rack_u_height) and not _overlaps(
|
||||
(row.u_start, model["u_height"], row.col_start, row.col_span), neighbours
|
||||
):
|
||||
u_height = model["u_height"]
|
||||
patch["u_height"] = u_height
|
||||
if _span_fits(row.col_start, model["col_span"]) and not _overlaps(
|
||||
(row.u_start, u_height, row.col_start, model["col_span"]), neighbours
|
||||
):
|
||||
patch["col_span"] = model["col_span"]
|
||||
return row.model_copy(update=patch)
|
||||
|
||||
|
||||
_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)
|
||||
|
||||
# The inventory row owns the front panel, so it wins over the mount's copy.
|
||||
inventory_ids = {d.device_id for d in devices if d.device_id}
|
||||
models: dict[str, dict[str, Any]] = {}
|
||||
if inventory_ids:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.id.in_(inventory_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
for row in rows:
|
||||
model = _rack_model(row)
|
||||
if model is not None:
|
||||
models[row.id] = model
|
||||
heights = {r.id: r.u_height for r in racks}
|
||||
|
||||
# An overlaid size must not land on the mount above. Footprints start as
|
||||
# persisted and are replaced by what the overlay actually applied, so a rack
|
||||
# of devices that all grew elsewhere resolves in one deterministic pass
|
||||
# instead of each one being measured against stale neighbours.
|
||||
boxes: dict[str, _Box] = {
|
||||
d.id: (d.u_start, d.u_height, d.col_start, d.col_span) for d in devices
|
||||
}
|
||||
mounted: list[RackDeviceResponse] = []
|
||||
for d in devices:
|
||||
placed = _with_model(
|
||||
d,
|
||||
models.get(d.device_id or ""),
|
||||
heights.get(d.rack_id),
|
||||
[boxes[o.id] for o in devices if o.id != d.id and o.rack_id == d.rack_id],
|
||||
)
|
||||
boxes[d.id] = (placed.u_start, placed.u_height, placed.col_start, placed.col_span)
|
||||
mounted.append(placed)
|
||||
|
||||
return RackStateResponse(
|
||||
racks=[RackResponse.model_validate(r) for r in racks],
|
||||
devices=mounted,
|
||||
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)
|
||||
# The size this mount held before the save, to tell a real resize from an
|
||||
# echo of what the load handed out. See the write-through below.
|
||||
was = (db_device.u_height, db_device.col_span) if db_device else None
|
||||
if db_device:
|
||||
for field, value in payload.items():
|
||||
setattr(db_device, field, value)
|
||||
else:
|
||||
db.add(RackDevice(**payload))
|
||||
|
||||
# Write-through: the device's front panel belongs to the inventory row,
|
||||
# not to this mount, so every rack showing the same device picks the
|
||||
# change up on its next load. Accessories have no row and keep theirs.
|
||||
if device_data.device_id:
|
||||
entry = await db.get(InventoryDevice, device_data.device_id)
|
||||
if entry is not None:
|
||||
entry.rack_faceplate_id = device_data.faceplate_id
|
||||
entry.rack_color = device_data.color
|
||||
entry.rack_ports = [p for p in device_data.ports if isinstance(p, dict)]
|
||||
# Size, unlike the plate, is not written back blind. The load
|
||||
# overlays it only where it still fits this rack, so a mount can
|
||||
# legitimately hold less than the row does — and saving that back
|
||||
# would shrink the device in every other rack. Only a size this
|
||||
# save actually changes travels; an unchanged one is an echo.
|
||||
if was is None or device_data.u_height != was[0]:
|
||||
entry.rack_u_height = device_data.u_height
|
||||
if was is None or device_data.col_span != was[1]:
|
||||
entry.rack_col_span = device_data.col_span
|
||||
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,
|
||||
rack_faceplate_id=device.rack_faceplate_id,
|
||||
rack_u_height=device.rack_u_height,
|
||||
rack_col_span=device.rack_col_span,
|
||||
rack_color=device.rack_color,
|
||||
rack_ports=device.rack_ports or [],
|
||||
)
|
||||
)
|
||||
return RackInventoryResponse(items=items)
|
||||
+525
-150
@@ -12,11 +12,36 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.database import AsyncSessionLocal, get_db
|
||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import (
|
||||
Design,
|
||||
Edge,
|
||||
InventoryDevice,
|
||||
InventoryDeviceLink,
|
||||
Node,
|
||||
RackDevice,
|
||||
ScanRun,
|
||||
)
|
||||
from app.schemas.nodes import NodeCreate
|
||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
|
||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||
from app.schemas.scan import (
|
||||
InventoryDeviceCreate,
|
||||
InventoryDeviceResponse,
|
||||
InventoryDeviceUpdate,
|
||||
ScanRunResponse,
|
||||
)
|
||||
from app.services.device_merge import merge_devices
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.doc_links import unlink_documents
|
||||
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,
|
||||
_valid_port_spec,
|
||||
request_cancel,
|
||||
run_device_scan,
|
||||
run_scan,
|
||||
)
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
merge_zigbee_properties,
|
||||
@@ -38,6 +63,15 @@ def _ip_tokens(ip: str | None) -> list[str]:
|
||||
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
|
||||
@@ -84,6 +118,13 @@ def merge_mac_property(
|
||||
return out
|
||||
|
||||
|
||||
class MergeDevicesRequest(BaseModel):
|
||||
"""Which inventory rows fold into which. ``winner_id`` is the survivor."""
|
||||
|
||||
winner_id: str
|
||||
loser_ids: list[str]
|
||||
|
||||
|
||||
class BulkActionRequest(BaseModel):
|
||||
device_ids: list[str]
|
||||
# Target design for approved nodes. Falls back to the first design when
|
||||
@@ -151,7 +192,39 @@ async def _background_scan(
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
run.status = "failed"
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _background_device_scan(
|
||||
run_id: str,
|
||||
device_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await run_device_scan(
|
||||
device_id,
|
||||
db,
|
||||
run_id,
|
||||
deep_scan=deep_scan or DeepScanOptions(),
|
||||
full_ports=full_ports,
|
||||
ports=ports,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Device scan run %s failed unexpectedly", run_id)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run and run.status == "running":
|
||||
# "error", the same word run_scan / run_device_scan write when
|
||||
# they fail themselves — one condition, one name. Scan History
|
||||
# filters and colours that one; "failed" showed up unlabelled.
|
||||
run.status = "error"
|
||||
await db.commit()
|
||||
|
||||
|
||||
@@ -190,6 +263,78 @@ async def trigger_scan(
|
||||
return run
|
||||
|
||||
|
||||
class RescanDeviceRequest(BaseModel):
|
||||
"""Per-device deep rescan. Defaults to every TCP port — that is the point.
|
||||
|
||||
``ports`` narrows the sweep to what the user typed in the deep-scan dialog
|
||||
(``80,443``, ``1-1024``); it wins over ``full_ports``.
|
||||
"""
|
||||
|
||||
full_ports: bool = True
|
||||
ports: str | None = None
|
||||
http_probe_enabled: bool | None = None
|
||||
verify_tls: bool | None = None
|
||||
|
||||
@field_validator("ports")
|
||||
@classmethod
|
||||
def _check_ports(cls, v: str | None) -> str | None:
|
||||
if v is None or not v.strip():
|
||||
return None
|
||||
spec = v.strip()
|
||||
if not _valid_port_spec(spec):
|
||||
raise ValueError("Invalid port range")
|
||||
return spec
|
||||
|
||||
|
||||
@router.post("/pending/{device_id}/rescan", response_model=ScanRunResponse)
|
||||
async def rescan_device(
|
||||
device_id: str,
|
||||
background_tasks: BackgroundTasks,
|
||||
payload: RescanDeviceRequest | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""Deep-rescan one known device to refresh its services (issue #350).
|
||||
|
||||
Recorded as a ScanRun like any other scan, so progress, stop and history
|
||||
work unchanged. One run per device at a time — a second request while the
|
||||
first is still scanning is a 409, not a duplicate nmap over 65535 ports.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if not device.ip:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="Device has no IP address to scan"
|
||||
)
|
||||
if device.status == "hidden":
|
||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
||||
|
||||
target = f"{device.ip}/32"
|
||||
running = (await db.execute(
|
||||
select(ScanRun).where(ScanRun.status == "running", ScanRun.kind == "device")
|
||||
)).scalars().all()
|
||||
if any(target in (r.ranges or []) for r in running):
|
||||
raise HTTPException(
|
||||
status_code=409, detail="A scan is already running for this device"
|
||||
)
|
||||
|
||||
p = payload or RescanDeviceRequest()
|
||||
deep_scan = _resolve_deep_scan(
|
||||
TriggerScanRequest(
|
||||
http_probe_enabled=p.http_probe_enabled, verify_tls=p.verify_tls
|
||||
)
|
||||
)
|
||||
run = ScanRun(status="running", kind="device", ranges=[target])
|
||||
db.add(run)
|
||||
await db.commit()
|
||||
await db.refresh(run)
|
||||
background_tasks.add_task(
|
||||
_background_device_scan, run.id, device_id, deep_scan, p.full_ports, p.ports
|
||||
)
|
||||
return run
|
||||
|
||||
|
||||
@router.post("/{run_id}/stop", response_model=dict)
|
||||
async def stop_scan(
|
||||
run_id: str,
|
||||
@@ -224,74 +369,47 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
||||
|
||||
|
||||
async def _canvas_correlation(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
db: AsyncSession, devices: list[InventoryDevice]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
|
||||
or ``ip``.
|
||||
"""Correlate each device to the canvas nodes drawing it.
|
||||
|
||||
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
||||
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
||||
(node counts are small for a homelab), so no N+1 per device.
|
||||
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.
|
||||
|
||||
IP matching is per-address: a node's ``ip`` may hold several comma-separated
|
||||
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
|
||||
the raw string. MAC is a stable identifier immune to such IP edits, so it is
|
||||
matched too — cumulatively with ieee/ip (issue #258).
|
||||
``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.ip,
|
||||
Node.mac,
|
||||
Node.ieee_address,
|
||||
Node.design_id,
|
||||
Node.created_at,
|
||||
Node.last_scan,
|
||||
Node.updated_at,
|
||||
Node.last_seen,
|
||||
).where(Node.design_id.isnot(None))
|
||||
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()
|
||||
# Index matching nodes by ip token, mac and ieee so a device can look up any.
|
||||
by_ip: dict[str, list[Any]] = {}
|
||||
by_mac: dict[str, list[Any]] = {}
|
||||
by_ieee: dict[str, list[Any]] = {}
|
||||
by_device: dict[str, list[Any]] = {}
|
||||
for row in rows:
|
||||
for tok in _ip_tokens(row.ip):
|
||||
by_ip.setdefault(tok, []).append(row)
|
||||
if row.mac:
|
||||
by_mac.setdefault(row.mac, []).append(row)
|
||||
if row.ieee_address:
|
||||
by_ieee.setdefault(row.ieee_address, []).append(row)
|
||||
by_device.setdefault(row.device_id, []).append(row)
|
||||
|
||||
info: dict[str, dict[str, Any]] = {}
|
||||
for d in devices:
|
||||
matched = []
|
||||
if d.ieee_address:
|
||||
matched += by_ieee.get(d.ieee_address, [])
|
||||
if d.mac:
|
||||
matched += by_mac.get(d.mac, [])
|
||||
for tok in _ip_tokens(d.ip):
|
||||
matched += by_ip.get(tok, [])
|
||||
# De-duplicate nodes matched by more than one identifier.
|
||||
matched = list({id(m): m for m in matched}.values())
|
||||
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": _agg([m.last_scan for m in matched], newest=True),
|
||||
"node_last_scan": d.last_scan,
|
||||
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
||||
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
|
||||
"node_last_seen": d.last_seen,
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
async def _with_canvas_counts(
|
||||
db: AsyncSession, devices: list[PendingDevice]
|
||||
) -> list[PendingDevice]:
|
||||
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:
|
||||
@@ -304,31 +422,252 @@ async def _with_canvas_counts(
|
||||
return devices
|
||||
|
||||
|
||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||
@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(PendingDevice).where(PendingDevice.status != "hidden"))
|
||||
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)
|
||||
async def clear_pending(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: 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"))
|
||||
pending_ids = (
|
||||
await db.execute(select(InventoryDevice.id).where(InventoryDevice.status == "pending"))
|
||||
).scalars().all()
|
||||
await unlink_documents(db, device_ids=list(pending_ids))
|
||||
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"))
|
||||
@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 unlink_documents(db, device_ids=[device.id])
|
||||
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.get("/pending/{device_id}/proxmox-children", response_model=list[InventoryDeviceResponse])
|
||||
async def list_proxmox_children(
|
||||
device_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> list[InventoryDevice]:
|
||||
"""Inventory rows for the guests a Proxmox host runs.
|
||||
|
||||
The Proxmox import records host -> guest as a ``device_inventory_links`` row
|
||||
with ``discovery_source == "proxmox"``, keyed by IEEE. This resolves those
|
||||
targets back to inventory rows so the UI can offer "add the children too"
|
||||
when a host is placed on a canvas. Empty for anything that is not a host.
|
||||
"""
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if not device.ieee_address:
|
||||
return []
|
||||
ieees = list(
|
||||
(
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink.target_ieee).where(
|
||||
InventoryDeviceLink.source_ieee == device.ieee_address,
|
||||
InventoryDeviceLink.discovery_source == "proxmox",
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not ieees:
|
||||
return []
|
||||
result = await db.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.ieee_address.in_(ieees),
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
||||
|
||||
|
||||
@router.post("/pending/merge", response_model=InventoryDeviceResponse)
|
||||
async def merge_inventory_devices(
|
||||
payload: MergeDevicesRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> InventoryDevice:
|
||||
"""Fold several inventory rows into one, keeping ``winner_id``.
|
||||
|
||||
The manual half of the duplicate repair. Identity matching only runs when a
|
||||
row is created, so two rows describing one host — minted before either
|
||||
carried the address that would have matched them — stay separate forever.
|
||||
Where they share no address at all, no automatic rule can join them safely
|
||||
(a name is not an identity), and this is the only way to say "these are the
|
||||
same machine".
|
||||
|
||||
Non-destructive: facts are unioned onto the survivor and every canvas node,
|
||||
rack mount and document is re-pointed at it before the extras go.
|
||||
"""
|
||||
winner = await db.get(InventoryDevice, payload.winner_id)
|
||||
if winner is None:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
loser_ids = [i for i in dict.fromkeys(payload.loser_ids) if i != winner.id]
|
||||
if not loser_ids:
|
||||
raise HTTPException(status_code=400, detail="Select at least two devices to merge")
|
||||
|
||||
losers = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(loser_ids)))
|
||||
).scalars().all()
|
||||
missing = set(loser_ids) - {row.id for row in losers}
|
||||
if missing:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
|
||||
await merge_devices(db, winner, list(losers))
|
||||
await db.commit()
|
||||
await db.refresh(winner)
|
||||
return (await _with_canvas_counts(db, [winner]))[0]
|
||||
|
||||
|
||||
@router.post("/pending/bulk-approve", response_model=dict)
|
||||
async def bulk_approve_devices(
|
||||
payload: BulkActionRequest,
|
||||
@@ -336,7 +675,7 @@ async def bulk_approve_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
# Repair any legacy same-canvas duplicate nodes before placing more.
|
||||
await dedupe_nodes_by_ieee(db)
|
||||
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
|
||||
@@ -350,97 +689,90 @@ async def bulk_approve_devices(
|
||||
# node was later deleted) must still be placeable on THIS design. Duplicates
|
||||
# are guarded per-design below, not by the global status flag.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status != "hidden",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status != "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
|
||||
# What already sits on the target canvas, so we skip devices already placed
|
||||
# here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
|
||||
# map to the existing node id so the skip report can point the user at it. A
|
||||
# value may be a Node still pending flush (in-batch duplicate) — resolved to
|
||||
# its id after the flush below. IPs are indexed per comma-separated token so
|
||||
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
|
||||
# 192.168.1.5 (issue #258).
|
||||
# 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.ip, Node.mac, Node.ieee_address).where(
|
||||
Node.design_id == default_design_id
|
||||
select(Node.id, Node.device_id).where(
|
||||
Node.design_id == default_design_id, Node.device_id.isnot(None)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
placed_ips: dict[str, Any] = {
|
||||
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
|
||||
}
|
||||
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
|
||||
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
|
||||
placed: dict[str, Any] = {device_id: nid for nid, device_id in existing}
|
||||
|
||||
created_nodes: list[Node] = []
|
||||
approved_devices: list[PendingDevice] = []
|
||||
approved_devices: list[InventoryDevice] = []
|
||||
skipped_devices: list[dict[str, Any]] = []
|
||||
for device in devices:
|
||||
# Record which identifier collided so the caller can explain each skip
|
||||
# (and, for existing on-canvas nodes, link to the node already there).
|
||||
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
|
||||
if ip_hit is not None:
|
||||
# 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.ip or "device",
|
||||
"match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
|
||||
"label": device.hostname or device.friendly_name or "device",
|
||||
"match": "rack", "value": "rack device", "_ref": None,
|
||||
})
|
||||
continue
|
||||
if device.ieee_address is not None and device.ieee_address in placed_ieee:
|
||||
# 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 device.ieee_address or "device",
|
||||
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
|
||||
})
|
||||
continue
|
||||
if device.mac is not None and device.mac in placed_mac:
|
||||
skipped_devices.append({
|
||||
"device_id": device.id,
|
||||
"label": device.hostname or device.friendly_name or device.mac or "device",
|
||||
"match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
|
||||
"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.friendly_name or device.ip or "device",
|
||||
type=node_type,
|
||||
ip=device.ip,
|
||||
mac=device.mac,
|
||||
hostname=device.hostname,
|
||||
status="online" if is_wireless else "unknown",
|
||||
services=device.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||
) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
|
||||
# Default to ping so the status checker actually polls the new node.
|
||||
# Without this the scheduler skips it (check_method NULL → no check).
|
||||
check_method="none" if is_wireless else ("ping" if device.ip else None),
|
||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
||||
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 (same ip/mac/ieee) is
|
||||
# not placed twice on the same canvas. Store the Node so a later in-batch
|
||||
# skip can resolve to its id after flush.
|
||||
for tok in _ip_tokens(device.ip):
|
||||
placed_ips[tok] = node
|
||||
if device.mac:
|
||||
placed_mac[device.mac] = node
|
||||
if device.ieee_address:
|
||||
placed_ieee[device.ieee_address] = node
|
||||
# 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]
|
||||
@@ -477,9 +809,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()
|
||||
@@ -495,7 +827,7 @@ async def restore_device(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
if device.status != "hidden":
|
||||
@@ -512,9 +844,9 @@ async def bulk_restore_devices(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(
|
||||
PendingDevice.id.in_(payload.device_ids),
|
||||
PendingDevice.status == "hidden",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.id.in_(payload.device_ids),
|
||||
InventoryDevice.status == "hidden",
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
@@ -537,7 +869,7 @@ async def approve_device(
|
||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
||||
node_design_id = first.id if first else None
|
||||
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
||||
@@ -549,6 +881,11 @@ async def approve_device(
|
||||
# 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
|
||||
@@ -569,31 +906,52 @@ async def approve_device(
|
||||
raise HTTPException(status_code=409, detail=conflict)
|
||||
|
||||
device.status = "approved"
|
||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||
# 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)
|
||||
node = Node(
|
||||
label=node_data.label,
|
||||
type=node_data.type,
|
||||
ip=node_data.ip,
|
||||
mac=_mac,
|
||||
hostname=node_data.hostname,
|
||||
status="online" if wireless else node_data.status,
|
||||
services=node_data.services or [],
|
||||
ieee_address=device.ieee_address,
|
||||
properties=_wireless_properties(
|
||||
|
||||
# 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(
|
||||
)
|
||||
if wireless
|
||||
else merge_mac_property(
|
||||
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
|
||||
_mac,
|
||||
),
|
||||
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
||||
check_target=None if wireless else node_data.check_target,
|
||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
||||
)
|
||||
)
|
||||
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=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()
|
||||
@@ -621,10 +979,10 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
|
||||
return False
|
||||
found = (
|
||||
await db.execute(
|
||||
select(PendingDeviceLink.id)
|
||||
select(InventoryDeviceLink.id)
|
||||
.where(
|
||||
PendingDeviceLink.discovery_source == "proxmox_cluster",
|
||||
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
|
||||
InventoryDeviceLink.discovery_source == "proxmox_cluster",
|
||||
(InventoryDeviceLink.source_ieee == ieee) | (InventoryDeviceLink.target_ieee == ieee),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
@@ -635,7 +993,7 @@ async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool
|
||||
async def _resolve_pending_links_for_ieee(
|
||||
db: AsyncSession, ieee: str | None, design_id: str | None
|
||||
) -> list[dict[str, str]]:
|
||||
"""Materialize edges for any pending_device_links involving ``ieee`` on the
|
||||
"""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
|
||||
@@ -649,9 +1007,9 @@ async def _resolve_pending_links_for_ieee(
|
||||
return []
|
||||
|
||||
links_q = await db.execute(
|
||||
select(PendingDeviceLink).where(
|
||||
(PendingDeviceLink.source_ieee == ieee)
|
||||
| (PendingDeviceLink.target_ieee == ieee)
|
||||
select(InventoryDeviceLink).where(
|
||||
(InventoryDeviceLink.source_ieee == ieee)
|
||||
| (InventoryDeviceLink.target_ieee == ieee)
|
||||
)
|
||||
)
|
||||
links = list(links_q.scalars().all())
|
||||
@@ -666,13 +1024,16 @@ async def _resolve_pending_links_for_ieee(
|
||||
for link in links
|
||||
}
|
||||
other_ieees.add(ieee)
|
||||
# A node reaches its ieee through the inventory row it draws.
|
||||
nodes_q = await db.execute(
|
||||
select(Node).where(
|
||||
Node.ieee_address.in_(other_ieees),
|
||||
select(InventoryDevice.ieee_address, Node)
|
||||
.join(Node, Node.device_id == InventoryDevice.id)
|
||||
.where(
|
||||
InventoryDevice.ieee_address.in_(other_ieees),
|
||||
Node.design_id == design_id,
|
||||
)
|
||||
)
|
||||
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||
by_ieee = {row_ieee: node for row_ieee, node in nodes_q.all() if row_ieee}
|
||||
|
||||
self_node = by_ieee.get(ieee)
|
||||
if self_node is None:
|
||||
@@ -751,7 +1112,7 @@ async def _resolve_pending_links_for_ieee(
|
||||
async def hide_device(
|
||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||
) -> dict[str, bool]:
|
||||
device = await db.get(PendingDevice, device_id)
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if not device:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
device.status = "hidden"
|
||||
@@ -763,9 +1124,10 @@ 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 unlink_documents(db, device_ids=[device.id])
|
||||
await db.delete(device)
|
||||
await db.commit()
|
||||
return {"ignored": True}
|
||||
@@ -777,6 +1139,19 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=ScanRunResponse)
|
||||
async def get_run(
|
||||
run_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ScanRun:
|
||||
"""One run, for a caller waiting on a scan it started (the device rescan)."""
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if not run:
|
||||
raise HTTPException(status_code=404, detail="Scan run not found")
|
||||
return run
|
||||
|
||||
|
||||
@router.get("/config", response_model=ScanConfig)
|
||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||
return ScanConfig(
|
||||
|
||||
@@ -4,13 +4,19 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
||||
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)
|
||||
|
||||
@@ -33,6 +39,10 @@ async def update_settings(
|
||||
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:
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -30,22 +30,38 @@ async def summary(
|
||||
"""
|
||||
_check_key(x_api_key)
|
||||
|
||||
# A node's reachability lives on the Device Inventory row it draws, so the
|
||||
# per-canvas counts come from the join. A node with no row (canvas
|
||||
# furniture) counts as unknown.
|
||||
status_rows = (
|
||||
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
||||
await db.execute(
|
||||
select(InventoryDevice.status_live, func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.group_by(InventoryDevice.status_live)
|
||||
)
|
||||
).all()
|
||||
counts = {row[0]: row[1] for row in status_rows}
|
||||
counts = {(row[0] or "unknown"): row[1] for row in status_rows}
|
||||
unlinked = (
|
||||
await db.execute(select(func.count()).select_from(Node).where(Node.device_id.is_(None)))
|
||||
).scalar_one()
|
||||
if unlinked:
|
||||
counts["unknown"] = counts.get("unknown", 0) + unlinked
|
||||
|
||||
pending = (
|
||||
await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PendingDevice)
|
||||
.where(PendingDevice.status == "pending")
|
||||
.select_from(InventoryDevice)
|
||||
.where(InventoryDevice.status == "pending")
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
zigbee = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
||||
select(func.count())
|
||||
.select_from(Node)
|
||||
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
|
||||
.where(InventoryDevice.ieee_address.isnot(None))
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
@@ -58,6 +74,8 @@ async def summary(
|
||||
"online": counts.get("online", 0),
|
||||
"offline": counts.get("offline", 0),
|
||||
"unknown": counts.get("unknown", 0),
|
||||
# Wire key kept from before the table was renamed to `device_inventory`:
|
||||
# the stats payload is a published contract, and the MCP server reads it.
|
||||
"pending_devices": pending,
|
||||
"zigbee_devices": zigbee,
|
||||
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
||||
|
||||
@@ -71,20 +71,36 @@ async def _broadcast(payload: str) -> None:
|
||||
_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(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
||||
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",
|
||||
"node_id": node_id,
|
||||
"device_id": device_id,
|
||||
"node_ids": node_ids,
|
||||
"services": services,
|
||||
"checked_at": checked_at,
|
||||
}))
|
||||
|
||||
@@ -13,12 +13,14 @@ 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 Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zigbee import (
|
||||
ZigbeeConfig,
|
||||
ZigbeeCoordinatorOut,
|
||||
ZigbeeEdgeOut,
|
||||
ZigbeeImportJob,
|
||||
ZigbeeImportJobResult,
|
||||
ZigbeeImportPendingResponse,
|
||||
ZigbeeImportRequest,
|
||||
ZigbeeImportResponse,
|
||||
@@ -27,7 +29,9 @@ from app.schemas.zigbee import (
|
||||
ZigbeeTestConnectionRequest,
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.import_jobs import create_job, fail_job, finish_job, get_job
|
||||
from app.services.inventory_sync import attach_device_ids
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zigbee_service import (
|
||||
build_zigbee_properties,
|
||||
fetch_networkmap,
|
||||
@@ -36,21 +40,82 @@ from app.services.zigbee_service import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZigbeeImportResponse)
|
||||
def _import_error(exc: BaseException) -> tuple[int, str]:
|
||||
"""Map a fetch_networkmap failure to the (status, detail) the API reports."""
|
||||
if isinstance(exc, ImportError):
|
||||
return 500, str(exc)
|
||||
if isinstance(exc, ConnectionError):
|
||||
return 502, str(exc)
|
||||
if isinstance(exc, TimeoutError):
|
||||
return 504, str(exc)
|
||||
if isinstance(exc, ValueError):
|
||||
return 422, str(exc)
|
||||
logger.exception("Unexpected error during Zigbee import", exc_info=exc)
|
||||
return 500, "Unexpected error during Zigbee import"
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZigbeeImportJob, status_code=202)
|
||||
async def import_zigbee_network(
|
||||
payload: ZigbeeImportRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportResponse:
|
||||
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
||||
) -> ZigbeeImportJob:
|
||||
"""Start a canvas import and return a job id to poll.
|
||||
|
||||
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.
|
||||
``<base_topic>/bridge/request/networkmap`` and waits up to
|
||||
``ZIGBEE_NETWORKMAP_TIMEOUT`` seconds (default 300) for the response.
|
||||
|
||||
The fetch runs in the background and the result is collected from
|
||||
``GET /zigbee/import/{job_id}``: a 200+ device mesh takes minutes to answer,
|
||||
which outlives the read timeout of any reverse proxy sitting in front of the
|
||||
API. Polling keeps each request short.
|
||||
"""
|
||||
job = create_job()
|
||||
background_tasks.add_task(_background_canvas_import, job.id, payload)
|
||||
return ZigbeeImportJob(job_id=job.id, status=job.status)
|
||||
|
||||
|
||||
@router.get("/import/{job_id}", response_model=ZigbeeImportJobResult)
|
||||
async def get_zigbee_import_job(
|
||||
job_id: str,
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZigbeeImportJobResult:
|
||||
"""Poll a canvas import started by ``POST /zigbee/import``.
|
||||
|
||||
While running, returns ``status="running"`` with no payload. On success the
|
||||
nodes and edges are carried in ``result``. A failed fetch is reported as the
|
||||
status code the synchronous route used to raise, so the client keeps
|
||||
distinguishing a bad broker (502) from a slow mesh (504).
|
||||
"""
|
||||
job = get_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Import job not found or expired")
|
||||
if job.status == "error":
|
||||
raise HTTPException(
|
||||
status_code=job.error_status or 500,
|
||||
detail=job.error or "Zigbee import failed",
|
||||
)
|
||||
result = None
|
||||
if job.status == "done" and job.result is not None:
|
||||
result = ZigbeeImportResponse(**job.result)
|
||||
return ZigbeeImportJobResult(job_id=job.id, status=job.status, result=result)
|
||||
|
||||
|
||||
async def _background_canvas_import(job_id: str, payload: ZigbeeImportRequest) -> None:
|
||||
"""Fetch the network map and park the canvas-ready payload on the job."""
|
||||
try:
|
||||
nodes_raw, edges_raw = await fetch_networkmap(
|
||||
mqtt_host=payload.mqtt_host,
|
||||
@@ -61,21 +126,31 @@ async def import_zigbee_network(
|
||||
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
|
||||
status, detail = _import_error(exc)
|
||||
fail_job(job_id, detail, status)
|
||||
return
|
||||
|
||||
# A canvas import is an import: the devices land in the Device Inventory
|
||||
# too, and each node carries the id of the row it draws so the canvas save
|
||||
# links to it instead of minting a second, IEEE-less row.
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||
nodes_raw = await attach_device_ids(db, nodes_raw)
|
||||
except Exception as exc:
|
||||
status, detail = _import_error(exc)
|
||||
fail_job(job_id, detail, status)
|
||||
return
|
||||
|
||||
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))
|
||||
finish_job(
|
||||
job_id,
|
||||
ZigbeeImportResponse(
|
||||
nodes=nodes, edges=edges, device_count=len(nodes)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
||||
@@ -185,14 +260,14 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZigbeeImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
||||
All zigbee-source links are wiped and re-inserted from the new map.
|
||||
"""
|
||||
# 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_ieee(db)
|
||||
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.
|
||||
@@ -213,62 +288,17 @@ async def _persist_pending_import(
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# If the device has already been approved as a canvas Node, refresh its
|
||||
# properties on every canvas it sits on. Still ensure the discovery
|
||||
# inventory carries a row for it (status="approved") so it shows in the
|
||||
# inventory list with an "In N canvas" badge — legacy auto-placed
|
||||
# coordinators never got a pending row, which is why they went missing.
|
||||
existing_nodes = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if existing_nodes:
|
||||
for existing_node in existing_nodes:
|
||||
existing_node.properties = merge_zigbee_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
suggested_type=n.get("type"),
|
||||
device_subtype=n.get("device_type"),
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
status="approved",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
# Refresh metadata but never change the row's status (an approved
|
||||
# device stays approved; a hidden one stays hidden).
|
||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||
inv.model = n.get("model") or inv.model
|
||||
inv.vendor = n.get("vendor") or inv.vendor
|
||||
if n.get("lqi") is not None:
|
||||
inv.lqi = n.get("lqi")
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -277,6 +307,7 @@ async def _persist_pending_import(
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
properties=props,
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
@@ -290,11 +321,11 @@ async def _persist_pending_import(
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if n.get("lqi") is not None:
|
||||
pending.lqi = n.get("lqi")
|
||||
if pending.status == "approved":
|
||||
# The device was approved earlier but its canvas Node no longer
|
||||
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
||||
# the row to "pending" so it reappears in the Pending list on
|
||||
# re-import instead of being silently swallowed. (Issue #167)
|
||||
pending.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.
|
||||
@@ -303,7 +334,7 @@ async def _persist_pending_import(
|
||||
|
||||
# Replace all zigbee-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
|
||||
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zigbee")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
@@ -315,7 +346,7 @@ async def _persist_pending_import(
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zigbee",
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink, Node, ScanRun
|
||||
from app.schemas.scan import ScanRunResponse
|
||||
from app.schemas.zwave import (
|
||||
ZwaveConfig,
|
||||
@@ -27,7 +27,8 @@ from app.schemas.zwave import (
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
from app.services.inventory_sync import attach_device_ids
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
from app.services.zwave_service import (
|
||||
build_zwave_properties,
|
||||
fetch_zwave_network,
|
||||
@@ -36,12 +37,22 @@ from app.services.zwave_service import (
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
|
||||
"""True while at least one canvas node still draws this device."""
|
||||
return (
|
||||
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
|
||||
).scalar_one_or_none() is not None
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/import", response_model=ZwaveImportResponse)
|
||||
async def import_zwave_network(
|
||||
payload: ZwaveImportRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: str = Depends(get_current_user),
|
||||
) -> ZwaveImportResponse:
|
||||
"""Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
|
||||
@@ -73,6 +84,12 @@ async def import_zwave_network(
|
||||
logger.exception("Unexpected error during Z-Wave import")
|
||||
raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
|
||||
|
||||
# A canvas import is an import: the devices land in the Device Inventory too,
|
||||
# and each node carries the id of the row it draws so the canvas save links
|
||||
# to it instead of minting a second, IEEE-less row.
|
||||
await _persist_pending_import(db, nodes_raw, edges_raw)
|
||||
nodes_raw = await attach_device_ids(db, nodes_raw)
|
||||
|
||||
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))
|
||||
@@ -182,14 +199,14 @@ async def _persist_pending_import(
|
||||
nodes_raw: list[dict[str, Any]],
|
||||
edges_raw: list[dict[str, Any]],
|
||||
) -> ZwaveImportPendingResponse:
|
||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
||||
"""Upsert nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Coordinator auto-approves to a canvas Node. Other devices upsert by 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_ieee(db)
|
||||
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.
|
||||
@@ -208,61 +225,17 @@ async def _persist_pending_import(
|
||||
# the pending inventory like every other device, so the user approves it
|
||||
# explicitly. Only the shared paths below run for it.
|
||||
|
||||
# Already approved as a canvas Node → refresh props on every canvas it
|
||||
# sits on. Still ensure the discovery inventory carries a row for it
|
||||
# (status="approved") so it shows in the inventory list with an
|
||||
# "In N canvas" badge — legacy auto-placed coordinators never got a
|
||||
# pending row, which is why they went missing.
|
||||
existing_nodes = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if existing_nodes:
|
||||
for existing_node in existing_nodes:
|
||||
existing_node.properties = merge_zwave_properties(
|
||||
existing_node.properties, props
|
||||
)
|
||||
inv = (
|
||||
await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if inv is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
suggested_type=n.get("type"),
|
||||
device_subtype=n.get("device_type"),
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
status="approved",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
)
|
||||
pending_created += 1
|
||||
else:
|
||||
# Refresh metadata but never change the row's status.
|
||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||
inv.model = n.get("model") or inv.model
|
||||
inv.vendor = n.get("vendor") or inv.vendor
|
||||
if n.get("lqi") is not None:
|
||||
inv.lqi = n.get("lqi")
|
||||
pending_updated += 1
|
||||
continue
|
||||
|
||||
# Properties belong to the inventory row now, so one upsert serves
|
||||
# every canvas drawing this device — there is no per-node refresh left
|
||||
# to do. The row's status is never touched here: an approved device
|
||||
# stays approved, a hidden one stays hidden.
|
||||
result = await db.execute(
|
||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
|
||||
)
|
||||
pending = result.scalar_one_or_none()
|
||||
if pending is None:
|
||||
db.add(
|
||||
PendingDevice(
|
||||
InventoryDevice(
|
||||
ieee_address=ieee,
|
||||
friendly_name=n.get("friendly_name"),
|
||||
hostname=n.get("friendly_name"),
|
||||
@@ -271,6 +244,7 @@ async def _persist_pending_import(
|
||||
model=n.get("model"),
|
||||
vendor=n.get("vendor"),
|
||||
lqi=n.get("lqi"),
|
||||
properties=props,
|
||||
status="pending",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
@@ -282,9 +256,10 @@ async def _persist_pending_import(
|
||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
||||
pending.model = n.get("model") or pending.model
|
||||
pending.vendor = n.get("vendor") or pending.vendor
|
||||
if pending.status == "approved":
|
||||
# Approved earlier but the canvas Node is gone (deleted) — revive
|
||||
# to "pending" so it reappears in the list instead of vanishing.
|
||||
pending.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
|
||||
@@ -292,7 +267,7 @@ async def _persist_pending_import(
|
||||
|
||||
# Replace all zwave-source links with the freshly discovered set.
|
||||
await db.execute(
|
||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
|
||||
sa_delete(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "zwave")
|
||||
)
|
||||
|
||||
links_recorded = 0
|
||||
@@ -304,7 +279,7 @@ async def _persist_pending_import(
|
||||
continue
|
||||
seen.add((src, tgt))
|
||||
db.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee=src,
|
||||
target_ieee=tgt,
|
||||
discovery_source="zwave",
|
||||
|
||||
@@ -2,12 +2,61 @@ import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def origin_of(url: str) -> str:
|
||||
"""`scheme://host[:port]` of an absolute URL, or "" when it has none."""
|
||||
parts = urlsplit(url)
|
||||
if not parts.scheme or not parts.netloc:
|
||||
return ""
|
||||
return f"{parts.scheme}://{parts.netloc}"
|
||||
|
||||
|
||||
OIDC_CALLBACK_PATH = "/api/v1/auth/oidc/callback"
|
||||
|
||||
|
||||
def app_base_path_of(redirect_uri: str) -> str:
|
||||
"""
|
||||
Path the SPA is served under, read back from OIDC_REDIRECT_URI.
|
||||
|
||||
The frontend can be mounted under a prefix (`VITE_BASE_PATH=/homelab/`), and the
|
||||
backend only ever learns about it through the callback URL the operator
|
||||
configured — `https://home.example/homelab/api/v1/auth/oidc/callback` means the
|
||||
app lives at `/homelab/`. Always returns a path with a trailing slash; `/` when
|
||||
the URI is unset, relative, or not a callback URL.
|
||||
"""
|
||||
path = urlsplit(redirect_uri).path
|
||||
if not path.endswith(OIDC_CALLBACK_PATH):
|
||||
return "/"
|
||||
base = path[: -len(OIDC_CALLBACK_PATH)]
|
||||
return f"{base}/" if base else "/"
|
||||
|
||||
def unquote_env(value: str) -> str:
|
||||
"""
|
||||
Drop one matched pair of surrounding quotes from an env value.
|
||||
|
||||
Secrets that contain `$` (bcrypt hashes, most OIDC client secrets) have to be
|
||||
single-quoted in `.env`, or Docker Compose eats the `$`. Compose then strips
|
||||
those quotes itself when it reads `env_file`, and so does python-dotenv on the
|
||||
`env_file=` path — but `podman-compose` (1.0.6) passes them through verbatim,
|
||||
so the container receives `'$2b$12$…'`, quotes included, and every bcrypt
|
||||
verify fails. Unwrap here rather than asking operators to quote differently
|
||||
per container runtime.
|
||||
|
||||
Only a *matched* pair is removed, so a genuinely malformed value still reaches
|
||||
the validation below instead of being silently patched up.
|
||||
"""
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
return value[1:-1]
|
||||
return value
|
||||
|
||||
|
||||
def _read_version() -> str:
|
||||
for candidate in [
|
||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
||||
@@ -52,6 +101,12 @@ class Settings(BaseSettings):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_auth_settings(self) -> "Settings":
|
||||
# Runtime-dependent quoting: see unquote_env. Applied to the three values
|
||||
# that routinely contain a `$` and so get quoted in .env.
|
||||
self.secret_key = unquote_env(self.secret_key)
|
||||
self.auth_password_hash = unquote_env(self.auth_password_hash)
|
||||
self.oidc_client_secret = unquote_env(self.oidc_client_secret)
|
||||
|
||||
h = self.auth_password_hash
|
||||
if h and not h.startswith("$2"):
|
||||
logger.error(
|
||||
@@ -88,6 +143,16 @@ class Settings(BaseSettings):
|
||||
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")
|
||||
app_origin = origin_of(self.oidc_redirect_uri)
|
||||
allowed = {value.rstrip("/") for value in self.cors_origins}
|
||||
if app_origin and app_origin not in allowed:
|
||||
logger.warning(
|
||||
"CORS_ORIGINS does not list %s (the origin of OIDC_REDIRECT_URI). "
|
||||
"The CSRF check accepts that origin anyway, but add it to CORS_ORIGINS "
|
||||
"so browser requests are not rejected: CORS_ORIGINS=[\"%s\"]",
|
||||
app_origin,
|
||||
app_origin,
|
||||
)
|
||||
return self
|
||||
|
||||
# Scanner
|
||||
@@ -98,6 +163,14 @@ class Settings(BaseSettings):
|
||||
# ports survive a timeout regardless; raise this on slow/overlay networks.
|
||||
scanner_version_host_timeout: int = 60
|
||||
|
||||
# Per-device deep rescan: total time budget in seconds, checked between port
|
||||
# slices. Unprivileged nmap falls back to a connect scan (-sT), and a host
|
||||
# that drops packets makes every filtered port wait out its RTT — the whole
|
||||
# range against such a host runs ~20-45 min. When the budget is spent the
|
||||
# run keeps what it found and says how much it did not reach. This is NOT an
|
||||
# nmap --host-timeout: that one discards a host's results wholesale.
|
||||
scanner_deep_host_timeout: int = 2700
|
||||
|
||||
# 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] = []
|
||||
@@ -152,6 +225,10 @@ class Settings(BaseSettings):
|
||||
zigbee_mqtt_tls_insecure: bool = False
|
||||
zigbee_sync_enabled: bool = False
|
||||
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
# Seconds to wait for the Z2M bridge to answer a networkmap request. A mesh
|
||||
# of 200+ devices can take several minutes to build the map, so raise this
|
||||
# if imports fail with "Timed out waiting for networkmap response".
|
||||
zigbee_networkmap_timeout: int = 300
|
||||
|
||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
||||
zwave_mqtt_host: str = ""
|
||||
@@ -165,6 +242,10 @@ class Settings(BaseSettings):
|
||||
zwave_sync_enabled: bool = False
|
||||
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
||||
|
||||
# Seconds to wait for any MQTT gateway request/response round-trip
|
||||
# (the Z-Wave node dump today). Same rationale as the Zigbee twin above.
|
||||
mqtt_response_timeout: int = 300
|
||||
|
||||
def _override_path(self) -> Path:
|
||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ 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.db.models import InventoryDevice, Node
|
||||
from app.services.status_checker import check_node, check_services
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,13 +23,23 @@ 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.
|
||||
@@ -37,45 +48,67 @@ 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
|
||||
])
|
||||
|
||||
|
||||
@@ -89,27 +122,35 @@ def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
||||
|
||||
|
||||
async def _run_service_checks() -> None:
|
||||
"""Check every service of every node and broadcast per-service results."""
|
||||
"""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:
|
||||
result = await db.execute(select(Node))
|
||||
nodes = result.scalars().all()
|
||||
devices = (
|
||||
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
|
||||
).scalars().all()
|
||||
node_map = await _nodes_by_device(db)
|
||||
checkable = [
|
||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
||||
for n in nodes
|
||||
if n.services
|
||||
(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 node_id, host, services in checkable:
|
||||
for device_id, node_ids, host, services in checkable:
|
||||
try:
|
||||
statuses = await check_services(host, services)
|
||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
||||
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 node %s: %s", node_id, exc)
|
||||
logger.error("Service checks failed for device %s: %s", device_id, exc)
|
||||
|
||||
|
||||
async def _run_proxmox_sync() -> None:
|
||||
|
||||
@@ -14,7 +14,7 @@ from jwt import InvalidTokenError
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.config import origin_of, settings
|
||||
|
||||
ACCESS_TOKEN_USE = "access"
|
||||
OIDC_SESSION_TOKEN_USE = "oidc_session"
|
||||
@@ -153,6 +153,13 @@ def origin_is_allowed(origin: str | None) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
|
||||
# A same-origin deployment behind a reverse proxy needs no CORS, so
|
||||
# CORS_ORIGINS is often left at its localhost default — yet browsers still
|
||||
# send Origin on unsafe methods. The OIDC callback URL is served by this app,
|
||||
# so its origin is the app's own and is always a legitimate CSRF origin.
|
||||
app_origin = origin_of(settings.oidc_redirect_uri)
|
||||
if app_origin:
|
||||
allowed_origins.add(app_origin)
|
||||
return origin.rstrip("/") in allowed_origins
|
||||
|
||||
|
||||
|
||||
@@ -204,6 +204,11 @@
|
||||
{"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"}
|
||||
|
||||
+670
-30
@@ -1,12 +1,14 @@
|
||||
import json as _json
|
||||
import logging
|
||||
import shutil
|
||||
import sqlite3
|
||||
import uuid as _uuid_mod
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import suppress
|
||||
from contextlib import closing, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -15,6 +17,59 @@ from app.core.config import APP_VERSION, settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# DDL for the documents feature that `Base.metadata.create_all` does not apply:
|
||||
# the column additions it never makes to an existing table, plus a virtual table
|
||||
# and partial indexes it cannot express at all. Shared with the test fixtures so
|
||||
# the suite runs against the same schema production boots with.
|
||||
DOCUMENT_DDL: tuple[tuple[str, str], ...] = (
|
||||
# `create_all` creates a missing table; it never adds a column to one that
|
||||
# already exists. A database that met an earlier shape of `documents` — an
|
||||
# intermediate build, a branch checked out and run before a column landed —
|
||||
# keeps that shape forever, and every query naming the new column fails with
|
||||
# `no such column`. So every nullable / defaulted column is (re)stated here
|
||||
# as an idempotent ALTER, the way the `device_inventory` migrations are.
|
||||
("documents.icon", "ALTER TABLE documents ADD COLUMN icon TEXT"),
|
||||
("documents.parent_id", "ALTER TABLE documents ADD COLUMN parent_id TEXT"),
|
||||
("documents.sort_order", "ALTER TABLE documents ADD COLUMN sort_order INTEGER DEFAULT 0"),
|
||||
("documents.device_id", "ALTER TABLE documents ADD COLUMN device_id TEXT"),
|
||||
("documents.node_id", "ALTER TABLE documents ADD COLUMN node_id TEXT"),
|
||||
("documents.design_id", "ALTER TABLE documents ADD COLUMN design_id TEXT"),
|
||||
("documents.frontmatter", "ALTER TABLE documents ADD COLUMN frontmatter JSON"),
|
||||
("documents.tags", "ALTER TABLE documents ADD COLUMN tags JSON"),
|
||||
("documents.starred", "ALTER TABLE documents ADD COLUMN starred BOOLEAN DEFAULT 0"),
|
||||
("documents.template_id", "ALTER TABLE documents ADD COLUMN template_id TEXT"),
|
||||
("documents.facts_snapshot", "ALTER TABLE documents ADD COLUMN facts_snapshot JSON"),
|
||||
("documents.facts_synced_at", "ALTER TABLE documents ADD COLUMN facts_synced_at DATETIME"),
|
||||
("documents.reviewed_at", "ALTER TABLE documents ADD COLUMN reviewed_at DATETIME"),
|
||||
# Set only when a body is edited, so "never touched since it was generated"
|
||||
# is distinguishable from "edited" — `created_at` and `updated_at` are two
|
||||
# separate clock reads on insert and are never equal.
|
||||
("documents.edited_at", "ALTER TABLE documents ADD COLUMN edited_at DATETIME"),
|
||||
("document_revisions.reason", "ALTER TABLE document_revisions ADD COLUMN reason TEXT"),
|
||||
(
|
||||
"documents_fts.table",
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5("
|
||||
"doc_id UNINDEXED, title, tags, body, "
|
||||
"tokenize = 'unicode61 remove_diacritics 2')",
|
||||
),
|
||||
(
|
||||
"documents.device_id.unique",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_documents_device "
|
||||
"ON documents(device_id) WHERE device_id IS NOT NULL",
|
||||
),
|
||||
(
|
||||
"documents.node_id.unique",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_documents_node "
|
||||
"ON documents(node_id) WHERE node_id IS NOT NULL",
|
||||
),
|
||||
(
|
||||
"documents.design_id.unique",
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_documents_design "
|
||||
"ON documents(design_id) WHERE design_id IS NOT NULL",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
||||
"""Run an idempotent migration statement, logging any error.
|
||||
|
||||
@@ -60,9 +115,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):
|
||||
@@ -114,11 +227,11 @@ async def init_db() -> None:
|
||||
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 pending_devices ADD COLUMN discovery_source TEXT")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_source TEXT")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN properties JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
|
||||
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) ---
|
||||
@@ -128,32 +241,32 @@ async def init_db() -> None:
|
||||
"nodes.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
||||
),
|
||||
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
|
||||
("device_inventory.ieee_address", "ALTER TABLE device_inventory ADD COLUMN ieee_address TEXT"),
|
||||
(
|
||||
"pending_devices.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)",
|
||||
"device_inventory.ieee_address.index",
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(ieee_address)",
|
||||
),
|
||||
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
|
||||
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
|
||||
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
|
||||
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
|
||||
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
|
||||
("device_inventory.friendly_name", "ALTER TABLE device_inventory ADD COLUMN friendly_name TEXT"),
|
||||
("device_inventory.device_subtype", "ALTER TABLE device_inventory ADD COLUMN device_subtype TEXT"),
|
||||
("device_inventory.model", "ALTER TABLE device_inventory ADD COLUMN model TEXT"),
|
||||
("device_inventory.vendor", "ALTER TABLE device_inventory ADD COLUMN vendor TEXT"),
|
||||
("device_inventory.lqi", "ALTER TABLE device_inventory ADD COLUMN lqi INTEGER"),
|
||||
]
|
||||
for label, sql in zigbee_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
|
||||
# Drop NOT NULL on device_inventory.ip (Zigbee devices have no IP).
|
||||
# SQLite can't ALTER column nullability — rebuild the table if needed.
|
||||
try:
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
|
||||
info = await conn.exec_driver_sql("PRAGMA table_info(device_inventory)")
|
||||
cols = info.fetchall()
|
||||
ip_col = next((c for c in cols if c[1] == "ip"), None)
|
||||
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
|
||||
if ip_col and ip_col[3] == 1:
|
||||
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
|
||||
logger.info("Migrating device_inventory: dropping NOT NULL on ip column")
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE TABLE pending_devices_new ("
|
||||
"CREATE TABLE device_inventory_new ("
|
||||
"id VARCHAR PRIMARY KEY,"
|
||||
"ip VARCHAR,"
|
||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
||||
@@ -171,25 +284,25 @@ async def init_db() -> None:
|
||||
")"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO pending_devices_new "
|
||||
"INSERT INTO device_inventory_new "
|
||||
"(id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||
"model, vendor, lqi, discovered_at) "
|
||||
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
|
||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
||||
"model, vendor, lqi, discovered_at FROM pending_devices"
|
||||
"model, vendor, lqi, discovered_at FROM device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
||||
await conn.exec_driver_sql("DROP TABLE device_inventory")
|
||||
await conn.exec_driver_sql(
|
||||
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
||||
"ALTER TABLE device_inventory_new RENAME TO device_inventory"
|
||||
)
|
||||
await conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
||||
"ON pending_devices(ieee_address)"
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_inventory_ieee_address "
|
||||
"ON device_inventory(ieee_address)"
|
||||
)
|
||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
||||
except OperationalError as exc:
|
||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
||||
logger.warning("device_inventory ip-nullable rebuild failed: %s", exc)
|
||||
# --- end Zigbee schema migrations -------------------------------------
|
||||
# --- Electrical designs schema migrations -----------------------------
|
||||
# Create designs table (idempotent)
|
||||
@@ -286,6 +399,34 @@ async def init_db() -> None:
|
||||
logger.warning("canvas_state migration failed: %s", exc)
|
||||
# --- end Electrical designs schema migrations --------------------------
|
||||
|
||||
# Rack cables gained editable annotations: a label the user can print on
|
||||
# the canvas, plus the same free-form property records nodes carry.
|
||||
rack_cable_migrations: list[tuple[str, str]] = [
|
||||
(
|
||||
"rack_cables.label_visible",
|
||||
"ALTER TABLE rack_cables ADD COLUMN label_visible BOOLEAN NOT NULL DEFAULT 0",
|
||||
),
|
||||
("rack_cables.properties", "ALTER TABLE rack_cables ADD COLUMN properties JSON"),
|
||||
]
|
||||
for label, sql in rack_cable_migrations:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE rack_cables SET properties = '[]' WHERE properties IS NULL"
|
||||
)
|
||||
|
||||
# A mount can override when its plate draws its sockets. Existing rows
|
||||
# keep the old behaviour, which is exactly what "auto" means.
|
||||
await _try_migrate(
|
||||
conn,
|
||||
"ALTER TABLE rack_devices ADD COLUMN port_visibility TEXT NOT NULL DEFAULT 'auto'",
|
||||
label="rack_devices.port_visibility",
|
||||
)
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE rack_devices SET port_visibility = 'auto' WHERE port_visibility IS NULL"
|
||||
)
|
||||
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
||||
with suppress(OperationalError):
|
||||
@@ -325,33 +466,532 @@ async def init_db() -> None:
|
||||
# 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 pending_devices ADD COLUMN discovery_sources JSON")
|
||||
await conn.exec_driver_sql("ALTER TABLE device_inventory ADD COLUMN discovery_sources JSON")
|
||||
with suppress(OperationalError):
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
|
||||
"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 pending_devices SET discovery_sources = json_array('arp') "
|
||||
"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 pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
|
||||
"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 pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
||||
"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)",
|
||||
),
|
||||
# Which of the row's services and properties this canvas shows, and
|
||||
# in what order. Seeded from the row further down, once the backfill
|
||||
# has had its say — see `_seed_node_views`.
|
||||
("nodes.display_view", "ALTER TABLE nodes ADD COLUMN display_view JSON"),
|
||||
# A zone or a group describes no device, so it has no inventory row to
|
||||
# keep its text on. Furniture keeps it here instead — see `Node.description`.
|
||||
("nodes.description", "ALTER TABLE nodes ADD COLUMN description TEXT"),
|
||||
):
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
# 3.3.x — the inventory row owns the device's rack modelisation: the
|
||||
# faceplate it wears, its size and its ports (labels, types, positions).
|
||||
# A mount reads them at load and writes them back on save, so a device
|
||||
# keeps the same front panel in every rack it is mounted in.
|
||||
for label, sql in (
|
||||
(
|
||||
"device_inventory.rack_faceplate_id",
|
||||
"ALTER TABLE device_inventory ADD COLUMN rack_faceplate_id TEXT",
|
||||
),
|
||||
(
|
||||
"device_inventory.rack_u_height",
|
||||
"ALTER TABLE device_inventory ADD COLUMN rack_u_height INTEGER",
|
||||
),
|
||||
(
|
||||
"device_inventory.rack_col_span",
|
||||
"ALTER TABLE device_inventory ADD COLUMN rack_col_span INTEGER",
|
||||
),
|
||||
("device_inventory.rack_color", "ALTER TABLE device_inventory ADD COLUMN rack_color TEXT"),
|
||||
("device_inventory.rack_ports", "ALTER TABLE device_inventory ADD COLUMN rack_ports JSON"),
|
||||
):
|
||||
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)
|
||||
|
||||
# Documents. create_all makes the two tables on a fresh database; this
|
||||
# brings an existing one up to the current shape — the columns create_all
|
||||
# would never add, the FTS5 index and the partial uniques that keep one
|
||||
# document per device / node / design.
|
||||
for label, sql in DOCUMENT_DDL:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
|
||||
await _backfill_node_devices()
|
||||
await _drop_legacy_node_columns()
|
||||
await _seed_node_views()
|
||||
await _backfill_zone_size()
|
||||
await _repair_self_parent_nodes()
|
||||
await _reindex_documents_fts()
|
||||
|
||||
|
||||
|
||||
# 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,"
|
||||
"display_view JSON,"
|
||||
"pos_x FLOAT,"
|
||||
"pos_y FLOAT,"
|
||||
"parent_id VARCHAR REFERENCES nodes(id) ON DELETE CASCADE,"
|
||||
"container_mode BOOLEAN,"
|
||||
"custom_colors JSON,"
|
||||
"custom_icon VARCHAR,"
|
||||
"description TEXT,"
|
||||
"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, display_view, pos_x, pos_y, parent_id, container_mode, "
|
||||
"custom_colors, custom_icon, description, 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)
|
||||
|
||||
|
||||
def _pre_split_backup() -> Path | None:
|
||||
"""The newest backup still holding the per-node device columns, if any.
|
||||
|
||||
`_backup_db` copies the database *before* the migrations of each new
|
||||
version, so a user who upgraded to 3.3.0 has a `homelab.db.back-3.3.0`
|
||||
carrying the last state in which `nodes` still owned its own services and
|
||||
properties. That copy is the only record of which canvas showed what, since
|
||||
3.3.0's backfill unioned them all onto one inventory row. Newest first: it is
|
||||
the state closest to the upgrade, so it is what the user last saw.
|
||||
"""
|
||||
db_path = Path(settings.sqlite_path)
|
||||
candidates = sorted(
|
||||
db_path.parent.glob(f"{db_path.name}.back-*"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
for path in candidates:
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(nodes)")}
|
||||
except sqlite3.Error:
|
||||
continue
|
||||
if {"services", "properties"} <= cols:
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _views_from_backup() -> dict[str, dict[str, Any]]:
|
||||
"""What each node drew, read out of the pre-3.3.0 backup. Empty when there is none.
|
||||
|
||||
Keyed by node id, which is a uuid and stable across every version. A backup
|
||||
that cannot be opened, or holds nodes this database no longer has, simply
|
||||
contributes nothing — the caller falls back to the inventory row.
|
||||
"""
|
||||
path = _pre_split_backup()
|
||||
if path is None:
|
||||
return {}
|
||||
try:
|
||||
with closing(sqlite3.connect(f"file:{path}?mode=ro", uri=True)) as conn:
|
||||
rows = conn.execute("SELECT id, services, properties FROM nodes").fetchall()
|
||||
except sqlite3.Error as exc:
|
||||
logger.warning("Could not read the pre-3.3.0 node lists from %s: %s", path.name, exc)
|
||||
return {}
|
||||
|
||||
out: dict[str, dict[str, Any]] = {}
|
||||
for node_id, services, properties in rows:
|
||||
drawn: dict[str, Any] = {}
|
||||
for kind, raw in (("services", services), ("properties", properties)):
|
||||
if isinstance(raw, str | bytes):
|
||||
with suppress(ValueError):
|
||||
decoded = _json.loads(raw)
|
||||
if isinstance(decoded, list):
|
||||
drawn[kind] = decoded
|
||||
if drawn:
|
||||
out[node_id] = drawn
|
||||
if out:
|
||||
logger.info("Recovering the per-canvas service/property layout from %s", path.name)
|
||||
return out
|
||||
|
||||
|
||||
async def _seed_node_views() -> None:
|
||||
"""Give pre-existing nodes an explicit view of their inventory row (3.3.3).
|
||||
|
||||
Order and visibility for services and properties moved to the node, so one
|
||||
device drawn on two canvases can be rendered two ways. A node from before
|
||||
that has no view, and where it comes from decides whether the user gets
|
||||
their arrangement back:
|
||||
|
||||
* upgrading from 3.2.0, the backfill has already seeded it from the node's
|
||||
own columns — nothing here to do;
|
||||
* upgrading from 3.3.0-3.3.2, those columns are gone and the row holds the
|
||||
union of every canvas, so the view is recovered from the backup taken
|
||||
before the 3.3.0 migration. That is the difference between a canvas coming
|
||||
back as the user left it and coming back showing every other canvas'
|
||||
properties;
|
||||
* with no usable backup, the row itself is the seed: what shows today keeps
|
||||
showing, and only what the row gains *later* is held back.
|
||||
|
||||
Never fatal: without a view the node simply shows the whole row, which is
|
||||
the behaviour it has now.
|
||||
"""
|
||||
# Imported here: app.services imports app.db.models, which imports this module.
|
||||
from app.services.inventory_sync import seed_node_views
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
seeded = await seed_node_views(session, drawn=_views_from_backup)
|
||||
if seeded:
|
||||
await session.commit()
|
||||
logger.info("Seeded the service/property view of %d node(s)", seeded)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Seeding the node service/property views failed: %s", exc)
|
||||
|
||||
|
||||
async def _backfill_zone_size() -> None:
|
||||
"""Move a zone's size out of the custom_colors blob into the real columns.
|
||||
|
||||
Every node type stored its size in `nodes.width` / `nodes.height` except
|
||||
`groupRect`, which stashed it inside the `custom_colors` JSON alongside its
|
||||
colours. The serializer writes the columns for zones too now, so a canvas
|
||||
saved before this upgrade would come back at the default 360x240 without
|
||||
this backfill.
|
||||
|
||||
Only fills a column that is still NULL, so it cannot overwrite a size the
|
||||
user has set since, and re-running it is a no-op. Parsed in Python rather
|
||||
than with `json_extract`, so it does not depend on the SQLite build being
|
||||
compiled with JSON1.
|
||||
|
||||
Never fatal: the reader falls back to the blob, so the worst case of a
|
||||
failure here is that the geometry keeps coming from where it always did.
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT id, custom_colors FROM nodes "
|
||||
"WHERE type = 'groupRect' AND custom_colors IS NOT NULL "
|
||||
"AND (width IS NULL OR height IS NULL)"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
moved = 0
|
||||
for node_id, blob in rows:
|
||||
if isinstance(blob, str):
|
||||
try:
|
||||
blob = _json.loads(blob)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(blob, dict):
|
||||
continue
|
||||
|
||||
width, height = blob.get("width"), blob.get("height")
|
||||
# A bool is an int in Python; a size that is not a real number
|
||||
# is left alone rather than written as garbage.
|
||||
if not isinstance(width, int | float) or isinstance(width, bool):
|
||||
width = None
|
||||
if not isinstance(height, int | float) or isinstance(height, bool):
|
||||
height = None
|
||||
if width is None and height is None:
|
||||
continue
|
||||
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET width = COALESCE(width, ?), height = COALESCE(height, ?) "
|
||||
"WHERE id = ?",
|
||||
(width, height, node_id),
|
||||
)
|
||||
moved += 1
|
||||
|
||||
if moved:
|
||||
logger.info("Moved the size of %d zone(s) to the width/height columns", moved)
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Backfilling zone width/height failed: %s", exc)
|
||||
|
||||
|
||||
async def _repair_self_parent_nodes() -> None:
|
||||
"""Detach any node that is recorded as its own parent.
|
||||
|
||||
Several write paths could persist ``parent_id = id`` before the guards
|
||||
added alongside this repair: a YAML import resolving a parent by a label
|
||||
that mapped back to the node, the node dedupe re-pointing a canonical node
|
||||
that had been nested under one of its own duplicates, and any client PATCH
|
||||
or canvas save, neither of which validated it (#370).
|
||||
|
||||
The row is fatal on the canvas: the parent walks assume an acyclic tree, so
|
||||
dragging the node overflowed the stack inside the change reducer and the
|
||||
move was silently dropped — the node selected but would not move. It also
|
||||
renders unparented, so it sits wherever its stored coordinates put it
|
||||
rather than inside the container it appears to belong to.
|
||||
|
||||
Clearing the column is the only safe repair: the real parent is not
|
||||
recoverable from the row, and NULL simply returns the node to the top level
|
||||
where the user can re-nest it. Idempotent — a second run matches nothing.
|
||||
|
||||
Never fatal: a failure here leaves the row as it was, and the runtime cycle
|
||||
guards keep the canvas usable either way.
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(
|
||||
"SELECT id, label FROM nodes WHERE parent_id IS NOT NULL AND parent_id = id"
|
||||
)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET parent_id = NULL WHERE parent_id IS NOT NULL AND parent_id = id"
|
||||
)
|
||||
for node_id, label in rows:
|
||||
logger.info("Detached self-parented node %s (%s)", node_id, label)
|
||||
logger.info("Repaired %d node(s) recorded as their own parent", len(rows))
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.warning("Repairing self-parented nodes failed: %s", exc)
|
||||
|
||||
|
||||
async def _reindex_documents_fts() -> None:
|
||||
"""Rebuild the document search index from the documents table.
|
||||
|
||||
The index is maintained on every write, so this only has to catch up the
|
||||
rows a write path could not have seen: the first boot after the feature
|
||||
lands, a restored backup, or a hand-edited database. Cheap enough to run on
|
||||
every boot — it does nothing once the counts already match.
|
||||
|
||||
Never fatal: SQLite may be built without FTS5, in which case the search
|
||||
endpoint falls back to LIKE and this is a no-op.
|
||||
"""
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
indexed = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents_fts")).scalar_one()
|
||||
total = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents")).scalar_one()
|
||||
if indexed == total:
|
||||
return
|
||||
await conn.exec_driver_sql("DELETE FROM documents_fts")
|
||||
rows = (
|
||||
await conn.exec_driver_sql("SELECT id, title, tags, body FROM documents")
|
||||
).fetchall()
|
||||
for doc_id, title, tags, body in rows:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO documents_fts (doc_id, title, tags, body) VALUES (?, ?, ?, ?)",
|
||||
(doc_id, title or "", _tags_text(tags), body or ""),
|
||||
)
|
||||
logger.info("Reindexed %d document(s) for search", len(rows))
|
||||
except Exception as exc: # pragma: no cover - defensive, boot must not die
|
||||
logger.debug("Document search index not rebuilt: %s", exc)
|
||||
|
||||
|
||||
def _tags_text(raw: Any) -> str:
|
||||
"""Flatten a document's JSON tag list into a single indexable string."""
|
||||
if isinstance(raw, str):
|
||||
with suppress(ValueError):
|
||||
raw = _json.loads(raw)
|
||||
if isinstance(raw, list):
|
||||
return " ".join(str(tag) for tag in raw)
|
||||
return ""
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
+259
-24
@@ -28,44 +28,54 @@ class Design(Base):
|
||||
|
||||
|
||||
class Node(Base):
|
||||
"""How a device is drawn on one canvas.
|
||||
|
||||
A node owns presentation only — position, size, colours, icon, handles,
|
||||
nesting. What the device *is* (addresses, services, properties, notes,
|
||||
hardware, check method, live status) belongs to the `device_inventory` row
|
||||
named by `device_id`, so one device reads the same on every canvas. The API
|
||||
still reports both together: see `services/inventory_sync.hydrated_node`.
|
||||
"""
|
||||
|
||||
__tablename__ = "nodes"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String)
|
||||
ip: Mapped[str | None] = mapped_column(String)
|
||||
mac: Mapped[str | None] = mapped_column(String)
|
||||
os: Mapped[str | None] = mapped_column(String)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
check_method: Mapped[str | None] = mapped_column(String)
|
||||
check_target: Mapped[str | None] = mapped_column(String)
|
||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
# The Device Inventory row this node draws. NULL for canvas furniture
|
||||
# (group / groupRect / text), which represents nothing physical. Deleting a
|
||||
# node never deletes the device — the inventory outlives every canvas.
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), index=True, nullable=True
|
||||
)
|
||||
# How this canvas renders the device's list facts: which services and which
|
||||
# properties it shows, and in what order. The facts themselves stay on the
|
||||
# inventory row, so the same device drawn on two canvases can show two
|
||||
# different subsets — a scanner-guessed service on one, none on the other.
|
||||
# {"services": [{"key": "443|tcp|https", "visible": true}, …],
|
||||
# "properties": [{"key": "rack", "visible": false}, …]}
|
||||
# NULL only for canvas furniture and for a node with no inventory row yet;
|
||||
# `inventory_sync.link_facts` fills both lists as soon as there is one.
|
||||
display_view: Mapped[dict[str, Any] | None] = mapped_column(JSON, 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)
|
||||
# What this piece of canvas furniture is for, in the user's words. Furniture
|
||||
# (group / groupRect / text) draws no device, so it has no inventory row to
|
||||
# carry a `notes` field — this column is that text's only home. NULL on every
|
||||
# node that does draw a device: its notes belong to the inventory row.
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
||||
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)
|
||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||
@@ -105,8 +115,109 @@ class CanvasState(Base):
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class PendingDevice(Base):
|
||||
__tablename__ = "pending_devices"
|
||||
class Rack(Base):
|
||||
"""A physical rack on a `design_type='rack'` canvas.
|
||||
|
||||
Racks live per design, like nodes and edges. Geometry is expressed in rack
|
||||
units: `u_height` is the number of mountable U, and a device's `u_start` is
|
||||
always counted from the bottom rail — `numbering` only changes the printed
|
||||
labels.
|
||||
"""
|
||||
|
||||
__tablename__ = "racks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=42)
|
||||
# "19" or "10" (inches). Drives the drawn inner width.
|
||||
width_standard: Mapped[str] = mapped_column(String, default="19")
|
||||
# "bottom-up" or "top-down" — printed U labels only, never storage order.
|
||||
numbering: Mapped[str] = mapped_column(String, default="bottom-up")
|
||||
location: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# Frame/rail/interior colours + showNumbers/enclosed flags.
|
||||
style: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||
|
||||
|
||||
class RackDevice(Base):
|
||||
"""A piece of gear mounted in a rack.
|
||||
|
||||
Two independent, both-optional links back to the rest of the app:
|
||||
|
||||
* `device_id` — the Device Inventory entry (`device_inventory`). This is the
|
||||
primary link: inventory rows survive approval *and* node deletion, so
|
||||
unracking or deleting a canvas node never removes the inventory entry.
|
||||
* `node_id` — the logical-canvas node, when one exists. Only used to resolve
|
||||
live status and to seed cables from the network design's edges.
|
||||
|
||||
Both are ``SET NULL`` on delete and `label` is denormalized, so a rack keeps
|
||||
rendering after an inventory purge.
|
||||
"""
|
||||
|
||||
__tablename__ = "rack_devices"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), nullable=False)
|
||||
rack_id: Mapped[str] = mapped_column(String, ForeignKey("racks.id", ondelete="CASCADE"), nullable=False)
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
node_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True)
|
||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||
# 1-based, counted from the bottom rail.
|
||||
u_start: Mapped[int] = mapped_column(Integer, default=1)
|
||||
u_height: Mapped[int] = mapped_column(Integer, default=1)
|
||||
# 12-column horizontal grid: full = 12, half = 6, third = 4, quarter = 3.
|
||||
col_start: Mapped[int] = mapped_column(Integer, default=0)
|
||||
col_span: Mapped[int] = mapped_column(Integer, default=12)
|
||||
faceplate_id: Mapped[str] = mapped_column(String, nullable=False, default="blank-1u")
|
||||
color: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String, default="unknown")
|
||||
# When the plate draws its sockets: "auto" (the faceplate decides), "always"
|
||||
# or "hover". A canvas display choice about the mount, not about the device.
|
||||
port_visibility: Mapped[str] = mapped_column(String, default="auto")
|
||||
# [{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
|
||||
@@ -138,6 +249,45 @@ class PendingDevice(Base):
|
||||
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
|
||||
)
|
||||
|
||||
# --- Rack modelisation (owned here, not by the mount) ------------------
|
||||
# A device wears the same front panel in every rack it is mounted in, so the
|
||||
# inventory row — not `rack_devices` — owns the faceplate, its size and its
|
||||
# ports. The mount keeps a denormalized copy so a rack still renders after an
|
||||
# inventory purge; `/api/v1/racks` overlays these on load and `save` writes
|
||||
# them back. NULL means "never modelled" — the mount's own copy then stands.
|
||||
rack_faceplate_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
rack_u_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
rack_col_span: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
rack_color: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
# [{id, label, type, x, y}] — x/y are unit coordinates on the plate.
|
||||
rack_ports: Mapped[list[Any] | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
# 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
|
||||
@@ -149,16 +299,16 @@ class PendingDevice(Base):
|
||||
node_last_seen: datetime | None = None
|
||||
|
||||
|
||||
class PendingDeviceLink(Base):
|
||||
class InventoryDeviceLink(Base):
|
||||
"""Link between two Zigbee endpoints discovered during import.
|
||||
|
||||
Endpoints are addressed by IEEE (stable across re-imports). Either side may
|
||||
already exist as a canvas Node (resolved via Node.ieee_address) or still be
|
||||
a PendingDevice. On approval, the matching Edge is auto-created when both
|
||||
a InventoryDevice. On approval, the matching Edge is auto-created when both
|
||||
endpoints exist as canvas Nodes.
|
||||
"""
|
||||
|
||||
__tablename__ = "pending_device_links"
|
||||
__tablename__ = "device_inventory_links"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
@@ -179,3 +329,88 @@ class ScanRun(Base):
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class Document(Base):
|
||||
"""One markdown document.
|
||||
|
||||
A document either stands alone in the Library tree (`kind` page/folder,
|
||||
placed by `parent_id`) or describes exactly one thing on the other side of
|
||||
an optional link: a Device Inventory row (`device_id`), a piece of canvas
|
||||
furniture such as a zone or a group (`node_id`), or a whole canvas
|
||||
(`design_id`). Only one of the three is ever set, and each is enforced
|
||||
unique by a partial index created in `database.init_db`.
|
||||
|
||||
Every link is `SET NULL` and `title` is denormalized, so deleting a device
|
||||
or a canvas never destroys what the user wrote — the document survives as
|
||||
an orphan and can be re-linked or filed away.
|
||||
|
||||
`body` is the whole markdown file, YAML frontmatter included, so a document
|
||||
exports to disk as-is. `frontmatter` and `tags` are a parsed cache of that
|
||||
block, kept only so listings can filter without reading every body.
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
kind: Mapped[str] = mapped_column(String, nullable=False, default="page")
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
||||
icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
# Library tree. Folders are documents too, so an empty folder is possible
|
||||
# and a folder can carry an index body. Only page/folder set this.
|
||||
parent_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("documents.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
device_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
node_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("nodes.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
design_id: Mapped[str | None] = mapped_column(
|
||||
String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
frontmatter: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
starred: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
template_id: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
# The device facts as they read when this document was scaffolded or last
|
||||
# reconciled. The header is generated once and then owned by the user, so
|
||||
# this is what tells the UI the document has drifted from the device.
|
||||
facts_snapshot: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
facts_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# Set the first time the body is edited by hand. NULL means the document is
|
||||
# still only what the template generated — which is what the coverage view
|
||||
# counts as "not really documented yet". A timestamp comparison cannot say
|
||||
# this: created_at and updated_at are two separate clock reads on insert.
|
||||
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
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 DocumentRevision(Base):
|
||||
"""A prior version of a document's body.
|
||||
|
||||
Written on every explicit save that actually changed the body, and pruned
|
||||
to the most recent `REVISION_LIMIT` per document in the same transaction.
|
||||
"""
|
||||
|
||||
__tablename__ = "document_revisions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||
document_id: Mapped[str] = mapped_column(
|
||||
String, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String, nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
reason: Mapped[str] = mapped_column(String, nullable=False, default="edit")
|
||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
+10
-1
@@ -12,11 +12,13 @@ from app.api.routes import (
|
||||
auth,
|
||||
canvas,
|
||||
designs,
|
||||
documents,
|
||||
edges,
|
||||
liveview,
|
||||
media,
|
||||
nodes,
|
||||
proxmox,
|
||||
racks,
|
||||
scan,
|
||||
stats,
|
||||
status,
|
||||
@@ -27,7 +29,8 @@ 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
|
||||
from app.db.database import AsyncSessionLocal, init_db
|
||||
from app.services.scanner import reconcile_orphan_runs
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -44,6 +47,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
|
||||
await init_db()
|
||||
settings.load_overrides()
|
||||
# A scan runs on a background thread in this process, so anything still
|
||||
# flagged "running" now was orphaned by a previous one that died mid-scan.
|
||||
async with AsyncSessionLocal() as db:
|
||||
await reconcile_orphan_runs(db)
|
||||
start_scheduler()
|
||||
yield
|
||||
stop_scheduler()
|
||||
@@ -77,6 +84,7 @@ 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"])
|
||||
@@ -86,6 +94,7 @@ 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.include_router(documents.router, prefix="/api/v1/documents", tags=["documents"])
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, ValidationInfo, field_validator, model_validator
|
||||
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
from app.schemas.utils import normalize_animated, normalize_marker
|
||||
from app.schemas.utils import clamp_handles, 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
|
||||
@@ -20,6 +31,9 @@ class NodeSave(BaseModel):
|
||||
check_target: str | None = None
|
||||
services: list[Any] = []
|
||||
notes: str | None = None
|
||||
# Canvas furniture only: the zone or group's own description. Furniture has no
|
||||
# inventory row, so this is the one place the text can live.
|
||||
description: str | None = None
|
||||
parent_id: str | None = None
|
||||
container_mode: bool = False
|
||||
custom_colors: dict[str, Any] | None = None
|
||||
@@ -40,6 +54,32 @@ class NodeSave(BaseModel):
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
# Same 0..64 clamp the create/update schemas apply, so a full-canvas save
|
||||
# cannot store a count the renderer would silently narrow.
|
||||
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
|
||||
@classmethod
|
||||
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
|
||||
if v is None:
|
||||
return None
|
||||
side = (info.field_name or '').removesuffix('_handles')
|
||||
return clamp_handles(side, v)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def drop_self_parent(self) -> "NodeSave":
|
||||
"""A node can never be its own parent.
|
||||
|
||||
Every parent walk on the canvas — waypoint propagation, ordering
|
||||
parents before children, collapse — assumes an acyclic tree, and a
|
||||
self-parent row makes the node undraggable once it is reloaded (#370).
|
||||
|
||||
Dropped rather than rejected: a canvas that already carries the bad row
|
||||
must still be able to save, and a 422 here would cost the user the whole
|
||||
save. The startup repair clears what is already persisted.
|
||||
"""
|
||||
if self.parent_id is not None and self.parent_id == self.id:
|
||||
self.parent_id = None
|
||||
return self
|
||||
|
||||
|
||||
class EdgeSave(BaseModel):
|
||||
id: str
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
# Kept in sync with DesignType in frontend/src/types.
|
||||
# "network" and "electrical" render the same React Flow canvas and differ only by
|
||||
# palette and node set — the icon drives their presentation. "rack" is a genuinely
|
||||
# different renderer (racks, mounted gear, port-to-port cables), so the frontend
|
||||
# does branch on this field.
|
||||
DESIGN_TYPES = {"network", "electrical", "rack"}
|
||||
|
||||
|
||||
class DesignCreate(BaseModel):
|
||||
name: str
|
||||
icon: str = "dashboard"
|
||||
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
|
||||
# the chosen icon now drives presentation. Defaults to a generic canvas.
|
||||
design_type: str = "network"
|
||||
|
||||
@field_validator("design_type")
|
||||
@classmethod
|
||||
def _known_type(cls, v: str) -> str:
|
||||
if v not in DESIGN_TYPES:
|
||||
raise ValueError(f"design_type must be one of {sorted(DESIGN_TYPES)}")
|
||||
return v
|
||||
|
||||
|
||||
class DesignUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.services.doc_template import TEMPLATE_IDS
|
||||
from app.services.doc_tree import DOCUMENT_KINDS
|
||||
|
||||
# Kept in sync with DocKind in frontend/src/documentation/types.ts.
|
||||
# "page" and "folder" live in the Library tree and carry a parent; "device",
|
||||
# "node" and "design" each point at exactly one thing elsewhere in the app and
|
||||
# are placed by that link instead, so the frontend can pivot them however the
|
||||
# user asks (by zone, subnet, type…) without the server storing a tree.
|
||||
DOC_KINDS = set(DOCUMENT_KINDS)
|
||||
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
kind: str = "page"
|
||||
title: str
|
||||
icon: str | None = None
|
||||
parent_id: str | None = None
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
design_id: str | None = None
|
||||
# Scaffolds the body when no body is supplied. "device" is implied for a
|
||||
# device document and is the only template that reads the live facts.
|
||||
template_id: str | None = None
|
||||
body: str | None = None
|
||||
|
||||
@field_validator("kind")
|
||||
@classmethod
|
||||
def _known_kind(cls, v: str) -> str:
|
||||
if v not in DOC_KINDS:
|
||||
raise ValueError(f"kind must be one of {sorted(DOC_KINDS)}")
|
||||
return v
|
||||
|
||||
@field_validator("template_id")
|
||||
@classmethod
|
||||
def _known_template(cls, v: str | None) -> str | None:
|
||||
if v is not None and v not in TEMPLATE_IDS:
|
||||
raise ValueError(f"template_id must be one of {sorted(TEMPLATE_IDS)}")
|
||||
return v
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
icon: str | None = None
|
||||
body: str | None = None
|
||||
parent_id: str | None = None
|
||||
sort_order: int | None = None
|
||||
starred: bool | None = None
|
||||
# Explicit: "I have re-read this and it is still true." Sent as a bare true
|
||||
# rather than a timestamp so the server owns the clock.
|
||||
reviewed: bool | None = None
|
||||
# Accept the device's current facts as documented, clearing the drift
|
||||
# banner without touching the body.
|
||||
resync_facts: bool | None = None
|
||||
|
||||
|
||||
class DocumentSummary(BaseModel):
|
||||
"""Everything the tree needs. Never carries a body — listings stay small."""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
title: str
|
||||
slug: str
|
||||
icon: str | None = None
|
||||
parent_id: str | None = None
|
||||
sort_order: int = 0
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
design_id: str | None = None
|
||||
tags: list[str] = []
|
||||
# The parsed frontmatter travels with the summary so the tree can badge a
|
||||
# document as due for review without fetching every body.
|
||||
frontmatter: dict[str, Any] = {}
|
||||
starred: bool = False
|
||||
template_id: str | None = None
|
||||
# Whether the device has moved on since the snapshot was taken. Computed by
|
||||
# the server because only the server knows the snapshot's shape: it holds
|
||||
# `label` and `type` through their fallbacks and `properties` as a flat
|
||||
# map, none of which the inventory wire shape can be compared against
|
||||
# field by field.
|
||||
drifted: bool = False
|
||||
reviewed_at: datetime | None = None
|
||||
edited_at: datetime | None = None
|
||||
facts_synced_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentResponse(DocumentSummary):
|
||||
body: str = ""
|
||||
facts_snapshot: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RevisionSummary(BaseModel):
|
||||
id: str
|
||||
document_id: str
|
||||
title: str
|
||||
reason: str
|
||||
saved_at: datetime
|
||||
# Character count, so the history list can show how much changed without
|
||||
# shipping every body.
|
||||
size: int = 0
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class RevisionResponse(RevisionSummary):
|
||||
body: str = ""
|
||||
|
||||
|
||||
class SearchHit(BaseModel):
|
||||
doc_id: str
|
||||
title: str
|
||||
kind: str
|
||||
snippet: str
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
# "fts5" or "like" — the UI drops snippet highlighting on the fallback.
|
||||
engine: str
|
||||
hits: list[SearchHit]
|
||||
|
||||
|
||||
class BacklinkHit(BaseModel):
|
||||
"""A document that links here, and the line it does it on."""
|
||||
|
||||
doc_id: str
|
||||
title: str
|
||||
kind: str
|
||||
device_id: str | None = None
|
||||
# What the link was written as, so a `[[…|label]]` reads back as the author
|
||||
# meant it rather than as the target's own title.
|
||||
label: str
|
||||
context: str
|
||||
count: int = 1
|
||||
|
||||
|
||||
class ScaffoldRequest(BaseModel):
|
||||
"""Create the missing device documents.
|
||||
|
||||
Omitting `device_ids` means every approved device that has none, which is
|
||||
the one-click migration off the old notes field.
|
||||
"""
|
||||
|
||||
device_ids: list[str] | None = None
|
||||
template_id: str = "device"
|
||||
# Only devices whose notes are not empty. What the migration banner sends.
|
||||
only_with_notes: bool = False
|
||||
|
||||
|
||||
class ScaffoldResponse(BaseModel):
|
||||
created: list[DocumentSummary]
|
||||
skipped: int
|
||||
|
||||
|
||||
class DriftField(BaseModel):
|
||||
field: str
|
||||
documented: Any = None
|
||||
current: Any = None
|
||||
|
||||
|
||||
class CoverageResponse(BaseModel):
|
||||
devices: int
|
||||
documented: int
|
||||
# A document whose body is still only what the template generated.
|
||||
header_only: int
|
||||
missing: int
|
||||
drifted: int
|
||||
overdue: int
|
||||
notes_unmigrated: int
|
||||
library_pages: int
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationInfo, field_validator
|
||||
|
||||
from app.schemas.utils import clamp_handles
|
||||
|
||||
|
||||
class NodeBase(BaseModel):
|
||||
@@ -16,6 +18,9 @@ class NodeBase(BaseModel):
|
||||
check_target: str | None = None
|
||||
services: list[Any] = []
|
||||
notes: str | None = None
|
||||
# Canvas furniture only (group / groupRect / text): what the zone or group is
|
||||
# for. A node that draws a device keeps its text on the inventory row instead.
|
||||
description: str | None = None
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
parent_id: str | None = None
|
||||
@@ -36,6 +41,18 @@ class NodeBase(BaseModel):
|
||||
left_handles: int = 0
|
||||
right_handles: int = 0
|
||||
|
||||
# Connection-point counts are clamped to 0..64 here rather than in the route:
|
||||
# the frontend clamps them again at render (handleUtils.clampHandles), so an
|
||||
# unclamped row would draw a different node than it stores. See #435 — the MCP
|
||||
# write tools reach these fields without the canvas UI's own bounds.
|
||||
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
|
||||
@classmethod
|
||||
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
|
||||
if v is None:
|
||||
return None
|
||||
side = (info.field_name or '').removesuffix('_handles')
|
||||
return clamp_handles(side, v)
|
||||
|
||||
|
||||
class NodeCreate(NodeBase):
|
||||
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
|
||||
@@ -61,6 +78,7 @@ class NodeUpdate(BaseModel):
|
||||
check_target: str | None = None
|
||||
services: list[Any] | None = None
|
||||
notes: str | None = None
|
||||
description: str | None = None
|
||||
pos_x: float | None = None
|
||||
pos_y: float | None = None
|
||||
parent_id: str | None = None
|
||||
@@ -81,10 +99,26 @@ class NodeUpdate(BaseModel):
|
||||
left_handles: int | None = None
|
||||
right_handles: int | None = None
|
||||
|
||||
# Connection-point counts are clamped to 0..64 here rather than in the route:
|
||||
# the frontend clamps them again at render (handleUtils.clampHandles), so an
|
||||
# unclamped row would draw a different node than it stores. See #435 — the MCP
|
||||
# write tools reach these fields without the canvas UI's own bounds.
|
||||
@field_validator('bottom_handles', 'top_handles', 'left_handles', 'right_handles', mode='before')
|
||||
@classmethod
|
||||
def clamp_handle_count(cls, v: object, info: ValidationInfo) -> object:
|
||||
if v is None:
|
||||
return None
|
||||
side = (info.field_name or '').removesuffix('_handles')
|
||||
return clamp_handles(side, v)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -41,6 +41,10 @@ class ProxmoxNodeOut(BaseModel):
|
||||
vendor: str | None = None
|
||||
model: str | None = None
|
||||
parent_ieee: str | None = None
|
||||
# The Device Inventory row this node draws, stamped by the import. The
|
||||
# canvas carries it back on save so the node links to that row rather
|
||||
# than minting a second one for the same device.
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
class ProxmoxEdgeOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
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"}
|
||||
# When a mount draws its ports; "auto" leaves the faceplate in charge.
|
||||
PORT_VISIBILITIES = {"auto", "always", "hover"}
|
||||
# 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"
|
||||
port_visibility: str = "auto"
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("port_visibility", mode="before")
|
||||
@classmethod
|
||||
def _known_port_visibility(cls, v: Any) -> str:
|
||||
"""Anything unknown — including the NULL of a pre-column row — is `auto`."""
|
||||
return str(v) if v in PORT_VISIBILITIES else "auto"
|
||||
|
||||
@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
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _devices_do_not_overlap(self) -> "RackSaveRequest":
|
||||
"""Two mounts may not claim the same square of a rack.
|
||||
|
||||
The canvas already refuses such a drop (`canPlace`), so this is the same
|
||||
rule stated where the state is persisted rather than where it is dragged
|
||||
— a stale tab, a hand-built payload or a bug upstream would otherwise
|
||||
store two plates drawn on top of each other, with no way to grab the one
|
||||
underneath.
|
||||
"""
|
||||
by_rack: dict[str, list[RackDeviceSave]] = {}
|
||||
for device in self.devices:
|
||||
by_rack.setdefault(device.rack_id, []).append(device)
|
||||
for mounts in by_rack.values():
|
||||
for i, a in enumerate(mounts):
|
||||
for b in mounts[i + 1 :]:
|
||||
if (
|
||||
a.u_start < b.u_start + b.u_height
|
||||
and b.u_start < a.u_start + a.u_height
|
||||
and a.col_start < b.col_start + b.col_span
|
||||
and b.col_start < a.col_start + a.col_span
|
||||
):
|
||||
raise ValueError(
|
||||
f"Devices {a.id} and {b.id} overlap in rack {a.rack_id}"
|
||||
)
|
||||
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
|
||||
port_visibility: str = "auto"
|
||||
ports: list[Any] = []
|
||||
|
||||
@field_validator("port_visibility", mode="before")
|
||||
@classmethod
|
||||
def _port_visibility_or_auto(cls, v: Any) -> str:
|
||||
"""Rows written before the column existed read back as NULL."""
|
||||
return str(v) if v in PORT_VISIBILITIES else "auto"
|
||||
|
||||
@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
|
||||
# Rack modelisation the inventory row owns — the same front panel wherever
|
||||
# the device is mounted. All None/empty for a device never modelled; the
|
||||
# tray then falls back to the type-suggested faceplate.
|
||||
rack_faceplate_id: str | None = None
|
||||
rack_u_height: int | None = None
|
||||
rack_col_span: int | None = None
|
||||
rack_color: str | None = None
|
||||
rack_ports: list[dict[str, Any]] = []
|
||||
|
||||
@field_validator("rack_ports", mode="before")
|
||||
@classmethod
|
||||
def _clean_ports(cls, v: Any) -> list[dict[str, Any]]:
|
||||
"""NULL for a row written before the column existed; junk stays out."""
|
||||
if not isinstance(v, list):
|
||||
return []
|
||||
return [p for p in v if isinstance(p, dict) and p.get("id")]
|
||||
|
||||
@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]
|
||||
+165
-2
@@ -3,8 +3,13 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.racks import RACK_COLUMNS
|
||||
|
||||
class PendingDeviceResponse(BaseModel):
|
||||
# Tallest plate the rack canvas offers; `MAX_RACK_U` on the frontend.
|
||||
MAX_PLATE_U = 48
|
||||
|
||||
|
||||
class InventoryDeviceResponse(BaseModel):
|
||||
id: str
|
||||
ip: str | None
|
||||
mac: str | None
|
||||
@@ -27,6 +32,26 @@ class PendingDeviceResponse(BaseModel):
|
||||
# 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
|
||||
@@ -37,16 +62,154 @@ class PendingDeviceResponse(BaseModel):
|
||||
node_last_scan: datetime | None = None
|
||||
node_last_modified: datetime | None = None
|
||||
node_last_seen: datetime | None = None
|
||||
# Rack modelisation: the front panel this device wears in every rack it is
|
||||
# mounted in. The row owns it — see `RackDevice` and `/api/v1/racks`. All
|
||||
# None/empty for a device that has never been racked or modelled.
|
||||
rack_faceplate_id: str | None = None
|
||||
rack_u_height: int | None = None
|
||||
rack_col_span: int | None = None
|
||||
rack_color: str | None = None
|
||||
rack_ports: list[Any] = []
|
||||
|
||||
@field_validator("properties", "discovery_sources", mode="before")
|
||||
@field_validator("rack_ports", mode="before")
|
||||
@classmethod
|
||||
def _coerce_rack_ports(cls, v: Any) -> list[Any]:
|
||||
"""NULL on every row written before the column existed."""
|
||||
return v if isinstance(v, list) else []
|
||||
|
||||
@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
|
||||
# Rack modelisation, edited from the Device Inventory as well as from a rack
|
||||
# canvas. Sending `rack_faceplate_id` is what models a device; the rest
|
||||
# describes the plate it wears.
|
||||
rack_faceplate_id: str | None = None
|
||||
rack_u_height: int | None = None
|
||||
rack_col_span: int | None = None
|
||||
rack_color: str | None = None
|
||||
rack_ports: list[Any] | None = None
|
||||
|
||||
@field_validator("rack_u_height")
|
||||
@classmethod
|
||||
def _plate_height(cls, v: int | None) -> int | None:
|
||||
if v is not None and not 1 <= v <= MAX_PLATE_U:
|
||||
raise ValueError(f"rack_u_height must be between 1 and {MAX_PLATE_U}")
|
||||
return v
|
||||
|
||||
@field_validator("rack_col_span")
|
||||
@classmethod
|
||||
def _plate_span(cls, v: int | None) -> int | None:
|
||||
if v is not None and not 1 <= v <= RACK_COLUMNS:
|
||||
raise ValueError(f"rack_col_span must be between 1 and {RACK_COLUMNS}")
|
||||
return v
|
||||
|
||||
@field_validator("rack_ports")
|
||||
@classmethod
|
||||
def _plate_ports(cls, v: list[Any] | None) -> list[Any] | None:
|
||||
"""A port with no id is not a port: no cable could ever name it."""
|
||||
if v is None:
|
||||
return None
|
||||
return [p for p in v if isinstance(p, dict) and p.get("id")]
|
||||
|
||||
|
||||
class ScanRunResponse(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import math
|
||||
import re
|
||||
|
||||
|
||||
def normalize_animated(v: object) -> str:
|
||||
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
|
||||
if v is True or v == 1 or v == '1':
|
||||
@@ -25,3 +29,84 @@ def normalize_marker(v: object) -> str:
|
||||
if isinstance(v, str) and v in MARKER_SHAPES:
|
||||
return v
|
||||
return 'none'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection points (React Flow handles)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mirrors frontend/src/utils/handleUtils.ts — keep the two in step.
|
||||
#
|
||||
# Handle IDs: slot 0 = the bare side name ('top' | 'bottom' | 'left' | 'right')
|
||||
# slot N >= 1 = '{side}-{N + 1}' (e.g. 'bottom-2', 'left-3')
|
||||
#
|
||||
# Slot 0 keeps the bare name so edges saved before the multi-handle expansion
|
||||
# stay valid. Invisible target handles carry a '-t' suffix on the frontend only;
|
||||
# what is stored on an edge is always the source form.
|
||||
|
||||
SIDES = ('top', 'bottom', 'left', 'right')
|
||||
|
||||
MIN_HANDLES = 0
|
||||
MAX_HANDLES = 64
|
||||
|
||||
|
||||
def side_default(side: str) -> int:
|
||||
"""Count a side falls back to when the node carries no explicit value.
|
||||
|
||||
Top/bottom default to their historical single handle; left/right to none, so
|
||||
an existing diagram gains no side handles unless the user opts in.
|
||||
"""
|
||||
return 1 if side in ('top', 'bottom') else 0
|
||||
|
||||
|
||||
def handle_count_field(side: str) -> str:
|
||||
"""The `nodes` column storing a side's handle count."""
|
||||
return f"{side}_handles"
|
||||
|
||||
|
||||
def clamp_handles(side: str, n: object) -> int:
|
||||
"""Clamp a raw count into 0..64.
|
||||
|
||||
A non-numeric or non-finite value falls back to the side default, so a
|
||||
corrupt field still yields the historical count; an explicit 0 is honoured.
|
||||
"""
|
||||
if isinstance(n, bool) or not isinstance(n, int | float):
|
||||
return side_default(side)
|
||||
if isinstance(n, float) and not math.isfinite(n):
|
||||
return side_default(side)
|
||||
return max(MIN_HANDLES, min(MAX_HANDLES, math.floor(n)))
|
||||
|
||||
|
||||
def handle_id(side: str, idx: int) -> str:
|
||||
"""The source handle ID at a given slot index for a side."""
|
||||
return side if idx == 0 else f"{side}-{idx + 1}"
|
||||
|
||||
|
||||
def removed_handle_ids(side: str, old_count: int, new_count: int) -> set[str]:
|
||||
"""The source handle IDs dropped when a side shrinks from old to new count."""
|
||||
return {handle_id(side, i) for i in range(new_count, old_count)}
|
||||
|
||||
|
||||
# A side handle ID, in either the source form ('left', 'left-3') or the
|
||||
# invisible target form the frontend renders alongside it ('left-t', 'left-3-t').
|
||||
_SIDE_HANDLE_RE = re.compile(r'^(top|bottom|left|right)(?:-(\d+))?(?:-t)?$')
|
||||
|
||||
|
||||
def parse_side_handle(handle: str) -> tuple[str, int] | None:
|
||||
"""Split a side handle ID into (side, slot index), or None when it isn't one.
|
||||
|
||||
A handle outside this vocabulary is left for its owner to interpret — the
|
||||
'cluster-*' IDs a specialized node renderer draws are not per-side handles
|
||||
and must not be validated against a side's count.
|
||||
|
||||
The numbered form is 2-based, because slot 0 keeps the bare side name: an
|
||||
ID ending in '-0' or '-1' is therefore one the canvas never emits, and is
|
||||
reported as slot -1 so that no count can satisfy it.
|
||||
"""
|
||||
m = _SIDE_HANDLE_RE.match(handle)
|
||||
if m is None:
|
||||
return None
|
||||
side, slot = m.group(1), m.group(2)
|
||||
if slot is None:
|
||||
return side, 0
|
||||
idx = int(slot) - 1
|
||||
return side, (idx if idx >= 1 else -1)
|
||||
|
||||
@@ -60,6 +60,10 @@ class ZigbeeNodeOut(BaseModel):
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
parent_id: str | None = None
|
||||
# The Device Inventory row this node draws, stamped by the import. The
|
||||
# canvas carries it back on save so the node links to that row rather
|
||||
# than minting a second one for the same device.
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
class ZigbeeEdgeOut(BaseModel):
|
||||
@@ -73,6 +77,21 @@ class ZigbeeImportResponse(BaseModel):
|
||||
device_count: int
|
||||
|
||||
|
||||
class ZigbeeImportJob(BaseModel):
|
||||
"""Handle returned when a canvas import is queued."""
|
||||
|
||||
job_id: str
|
||||
status: str # running | done | error
|
||||
|
||||
|
||||
class ZigbeeImportJobResult(BaseModel):
|
||||
"""Poll response for a canvas import. ``result`` is set once done."""
|
||||
|
||||
job_id: str
|
||||
status: str # running | done
|
||||
result: ZigbeeImportResponse | None = None
|
||||
|
||||
|
||||
class ZigbeeTestConnectionResponse(BaseModel):
|
||||
connected: bool
|
||||
message: str
|
||||
|
||||
@@ -50,6 +50,10 @@ class ZwaveNodeOut(BaseModel):
|
||||
vendor: str | None = None
|
||||
lqi: int | None = None
|
||||
parent_id: str | None = None
|
||||
# The Device Inventory row this node draws, stamped by the import. The
|
||||
# canvas carries it back on save so the node links to that row rather
|
||||
# than minting a second one for the same device.
|
||||
device_id: str | None = None
|
||||
|
||||
|
||||
class ZwaveEdgeOut(BaseModel):
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""Fold several `device_inventory` rows describing one host into a single row.
|
||||
|
||||
The inventory matches on identity *when a row is created* (``find_device_for``:
|
||||
ieee > ip > mac) and never looks again. A row minted while its facts were still
|
||||
incomplete — a canvas node saved before anyone typed its IP, a legacy node the
|
||||
3.3.0 backfill could only see by label — therefore stays a second row forever,
|
||||
even once the missing address arrives and both rows plainly describe the same
|
||||
machine. This module is the repair for that, in two shapes:
|
||||
|
||||
* :func:`merge_devices` — the user picks the survivor and what folds into it.
|
||||
The only way to collapse rows that share nothing an automatic rule can trust:
|
||||
a name is not an identity.
|
||||
* :func:`reconcile_duplicates` — run after an import, collapses only what a
|
||||
shared MAC proves. Never a shared IP alone: that is not an identity.
|
||||
|
||||
Merging is non-destructive by construction: facts are unioned, and every
|
||||
reference (`nodes`, `rack_devices`, `documents`, mesh links) is re-pointed onto
|
||||
the survivor *before* the extras go. That is what separates it from the
|
||||
scanner's older collapse, which deleted duplicate rows outright and left canvas
|
||||
nodes naming a row that no longer existed — SQLite runs with foreign keys off
|
||||
here, so the declared ``ON DELETE SET NULL`` never fires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document, InventoryDevice, InventoryDeviceLink, Node, RackDevice
|
||||
from app.services.discovery_sources import add_source
|
||||
from app.services.inventory_sync import (
|
||||
VIEW_LISTS,
|
||||
ip_tokens,
|
||||
merge_properties,
|
||||
merge_services,
|
||||
view_of_device,
|
||||
)
|
||||
from app.services.node_dedupe import dedupe_nodes_by_device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Scalars the survivor fills from a loser when — and only when — it has none of
|
||||
# its own. `ip`, `mac`, the lists and the timestamps are merged by rule below.
|
||||
_FILL_SCALARS = (
|
||||
"hostname",
|
||||
"os",
|
||||
"suggested_type",
|
||||
"discovery_source",
|
||||
"friendly_name",
|
||||
"device_subtype",
|
||||
"model",
|
||||
"vendor",
|
||||
"lqi",
|
||||
"label",
|
||||
"type",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"check_method",
|
||||
"check_target",
|
||||
"response_time_ms",
|
||||
"rack_faceplate_id",
|
||||
"rack_u_height",
|
||||
"rack_col_span",
|
||||
"rack_color",
|
||||
"rack_ports",
|
||||
)
|
||||
|
||||
|
||||
def _blank(value: Any) -> bool:
|
||||
return value is None or value == ""
|
||||
|
||||
|
||||
def _newest(left: datetime | None, right: datetime | None) -> datetime | None:
|
||||
if left is None or right is None:
|
||||
return left or right
|
||||
return max(left, right)
|
||||
|
||||
|
||||
def _merged_ip(winner: str | None, loser: str | None) -> str | None:
|
||||
"""Union of both address lists, the survivor's own addresses first.
|
||||
|
||||
A device legitimately holds several addresses (a management VLAN, a second
|
||||
NIC), and dropping the loser's would undo the very match that identified it
|
||||
— the next import would mint the duplicate again.
|
||||
"""
|
||||
tokens = ip_tokens(winner)
|
||||
for token in ip_tokens(loser):
|
||||
if token not in tokens:
|
||||
tokens.append(token)
|
||||
return ", ".join(tokens) or None
|
||||
|
||||
|
||||
def _merged_status(winner: str, losers: list[InventoryDevice]) -> str:
|
||||
"""The lifecycle state the survivor carries.
|
||||
|
||||
``approved`` wins over everything: a canvas node or a rack mount is about to
|
||||
point at this row, and a row that is pending or hidden would file a drawn
|
||||
device back in the discovery queue or make it vanish from the inventory.
|
||||
"""
|
||||
if winner == "approved" or any(loser.status == "approved" for loser in losers):
|
||||
return "approved"
|
||||
return winner
|
||||
|
||||
|
||||
async def _merge_documents(db: AsyncSession, winner: InventoryDevice, loser_ids: list[str]) -> int:
|
||||
"""Re-point the losers' documents, or orphan them. Returns how many orphaned.
|
||||
|
||||
``documents.device_id`` is unique per device (a partial index built in
|
||||
``init_db``), so only one document can describe the survivor. The oldest of
|
||||
the losers' is adopted when the survivor has none; the rest are unlinked
|
||||
rather than deleted — what the user wrote outlives the row it described, and
|
||||
an orphan keeps its denormalized title and can be re-linked from the Library.
|
||||
"""
|
||||
docs = (
|
||||
await db.execute(
|
||||
select(Document)
|
||||
.where(Document.device_id.in_(loser_ids))
|
||||
.order_by(Document.created_at, Document.id)
|
||||
)
|
||||
).scalars().all()
|
||||
if not docs:
|
||||
return 0
|
||||
|
||||
has_own = (
|
||||
await db.execute(select(Document.id).where(Document.device_id == winner.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
orphaned = 0
|
||||
for i, doc in enumerate(docs):
|
||||
if i == 0 and has_own is None:
|
||||
doc.device_id = winner.id
|
||||
continue
|
||||
doc.device_id = None
|
||||
orphaned += 1
|
||||
return orphaned
|
||||
|
||||
|
||||
async def _show_merged_facts(db: AsyncSession, winner: InventoryDevice) -> int:
|
||||
"""Make the survivor's facts visible on every canvas that draws it.
|
||||
|
||||
A node's ``display_view`` is a whitelist: ``apply_view`` appends anything the
|
||||
view does not list *hidden*, which is what keeps a service a later scan found
|
||||
off canvases nobody has enabled it on. After a merge that rule works against
|
||||
the user — a node that came from the poorer row lists only the poorer row's
|
||||
keys, so every service and property it just gained would arrive invisible,
|
||||
and the canvas would look exactly as bare as before the merge.
|
||||
|
||||
So each node drawing the survivor has the keys missing from its view appended
|
||||
as shown. Only *missing* keys: a fact this canvas listed as hidden was hidden
|
||||
on purpose and stays that way. Returns the number of nodes touched.
|
||||
"""
|
||||
nodes = (
|
||||
await db.execute(select(Node).where(Node.device_id == winner.id))
|
||||
).scalars().all()
|
||||
seeds = view_of_device(winner)
|
||||
touched = 0
|
||||
for node in nodes:
|
||||
view = dict(node.display_view or {})
|
||||
changed = False
|
||||
for kind in VIEW_LISTS:
|
||||
entries = view.get(kind)
|
||||
if not isinstance(entries, list):
|
||||
# No view at all for this list — `apply_view` already shows
|
||||
# everything the row holds, merged facts included.
|
||||
continue
|
||||
listed = {str(e.get("key")) for e in entries if isinstance(e, dict)}
|
||||
missing = [entry for entry in seeds[kind] if entry["key"] not in listed]
|
||||
if missing:
|
||||
view[kind] = [*entries, *missing]
|
||||
changed = True
|
||||
if changed:
|
||||
# Reassign rather than mutate: a JSON column tracks identity, not
|
||||
# in-place edits, so a mutated dict would never be written back.
|
||||
node.display_view = view
|
||||
touched += 1
|
||||
return touched
|
||||
|
||||
|
||||
async def _refresh_rack_mounts(db: AsyncSession, winner: InventoryDevice, losers: list[InventoryDevice]) -> None:
|
||||
"""Point the losers' rack mounts at the survivor and refresh what they copied.
|
||||
|
||||
A mount denormalizes the device's ``label`` so a rack still renders after an
|
||||
inventory purge. Re-pointing alone would leave it printing the name of a row
|
||||
that no longer exists, so a copy that still matches the loser is refreshed to
|
||||
the survivor's name — a label the user typed on the mount itself does not
|
||||
match and is left alone. The faceplate, its size and its ports are overlaid
|
||||
from the inventory row on load, and the merge already filled any the survivor
|
||||
lacked, so the plate keeps rendering as it did.
|
||||
"""
|
||||
loser_ids = [row.id for row in losers]
|
||||
mounts = (
|
||||
await db.execute(select(RackDevice).where(RackDevice.device_id.in_(loser_ids)))
|
||||
).scalars().all()
|
||||
stale = {
|
||||
name
|
||||
for row in losers
|
||||
for name in (row.label, row.friendly_name, row.hostname)
|
||||
if name
|
||||
}
|
||||
winner_name = winner.label or winner.friendly_name or winner.hostname
|
||||
for mount in mounts:
|
||||
mount.device_id = winner.id
|
||||
if winner_name and mount.label in stale:
|
||||
mount.label = winner_name
|
||||
|
||||
|
||||
async def _rewrite_links(db: AsyncSession, winner_ieee: str, loser_ieees: list[str]) -> None:
|
||||
"""Move mesh/Proxmox links off the losers' IEEEs onto the survivor's.
|
||||
|
||||
``device_inventory_links`` addresses endpoints by IEEE, not by row id, so a
|
||||
link left on a merged-away address stops resolving to anything. Rewriting
|
||||
can produce a self-link (both endpoints were the same device) or a duplicate
|
||||
pair; both are dropped.
|
||||
"""
|
||||
if not loser_ieees:
|
||||
return
|
||||
links = (
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink).where(
|
||||
or_(
|
||||
InventoryDeviceLink.source_ieee.in_(loser_ieees),
|
||||
InventoryDeviceLink.target_ieee.in_(loser_ieees),
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
for link in links:
|
||||
if link.source_ieee in loser_ieees:
|
||||
link.source_ieee = winner_ieee
|
||||
if link.target_ieee in loser_ieees:
|
||||
link.target_ieee = winner_ieee
|
||||
await db.flush()
|
||||
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
all_links = (
|
||||
await db.execute(
|
||||
select(InventoryDeviceLink)
|
||||
.where(
|
||||
or_(
|
||||
InventoryDeviceLink.source_ieee == winner_ieee,
|
||||
InventoryDeviceLink.target_ieee == winner_ieee,
|
||||
)
|
||||
)
|
||||
.order_by(InventoryDeviceLink.discovered_at, InventoryDeviceLink.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for link in all_links:
|
||||
key = (link.source_ieee, link.target_ieee, link.discovery_source)
|
||||
if link.source_ieee == link.target_ieee or key in seen:
|
||||
await db.delete(link)
|
||||
continue
|
||||
seen.add(key)
|
||||
|
||||
|
||||
async def merge_devices(
|
||||
db: AsyncSession,
|
||||
winner: InventoryDevice,
|
||||
losers: list[InventoryDevice],
|
||||
) -> dict[str, Any]:
|
||||
"""Fold ``losers`` into ``winner``. Does not commit — the caller owns that.
|
||||
|
||||
The survivor keeps every fact it already has; a loser only fills a gap. That
|
||||
rule is the whole safety of the operation: whichever row the user (or the
|
||||
reconcile pass) picked, nothing it recorded is overwritten by an older or
|
||||
less-curated copy, and nothing the extras recorded is lost — lists are
|
||||
unioned, addresses are unioned, sources accumulate.
|
||||
|
||||
Returns counts for the caller to report.
|
||||
"""
|
||||
losers = [row for row in losers if row.id != winner.id]
|
||||
if not losers:
|
||||
return {
|
||||
"merged": 0,
|
||||
"device_id": winner.id,
|
||||
"nodes_repointed": 0,
|
||||
"documents_orphaned": 0,
|
||||
"views_extended": 0,
|
||||
}
|
||||
|
||||
loser_ids = [row.id for row in losers]
|
||||
# Oldest first: where two losers both fill the same gap, the older sighting
|
||||
# is the one that has survived longest without being contradicted.
|
||||
for loser in sorted(losers, key=lambda d: (d.discovered_at, d.id)):
|
||||
for field in _FILL_SCALARS:
|
||||
if _blank(getattr(winner, field, None)):
|
||||
value = getattr(loser, field, None)
|
||||
if not _blank(value):
|
||||
setattr(winner, field, value)
|
||||
winner.ip = _merged_ip(winner.ip, loser.ip)
|
||||
winner.mac = winner.mac or loser.mac
|
||||
winner.services = merge_services(winner.services, loser.services)
|
||||
winner.properties = merge_properties(winner.properties, loser.properties)
|
||||
winner.show_hardware = winner.show_hardware or loser.show_hardware
|
||||
for source in add_source(loser.discovery_sources, loser.discovery_source):
|
||||
winner.discovery_sources = add_source(winner.discovery_sources, source)
|
||||
if winner.status_live in (None, "", "unknown"):
|
||||
winner.status_live = loser.status_live
|
||||
winner.last_seen = _newest(winner.last_seen, loser.last_seen)
|
||||
winner.last_scan = _newest(winner.last_scan, loser.last_scan)
|
||||
# The device has been known since the earliest of the rows saw it.
|
||||
if loser.discovered_at and winner.discovered_at:
|
||||
winner.discovered_at = min(winner.discovered_at, loser.discovered_at)
|
||||
|
||||
winner.status = _merged_status(winner.status, losers)
|
||||
|
||||
nodes = await db.execute(
|
||||
update(Node).where(Node.device_id.in_(loser_ids)).values(device_id=winner.id)
|
||||
)
|
||||
await _refresh_rack_mounts(db, winner, losers)
|
||||
orphaned = await _merge_documents(db, winner, loser_ids)
|
||||
await db.flush()
|
||||
shown = await _show_merged_facts(db, winner)
|
||||
|
||||
loser_ieees = [row.ieee_address for row in losers if row.ieee_address]
|
||||
adopted: str | None = None
|
||||
if _blank(winner.ieee_address) and loser_ieees:
|
||||
# `ieee_address` is UNIQUE, so the address can only move once the row
|
||||
# holding it is gone — assigned after the delete below.
|
||||
adopted = loser_ieees[0]
|
||||
|
||||
for loser in losers:
|
||||
await db.delete(loser)
|
||||
await db.flush()
|
||||
|
||||
if adopted is not None:
|
||||
winner.ieee_address = adopted
|
||||
await db.flush()
|
||||
if winner.ieee_address:
|
||||
await _rewrite_links(db, winner.ieee_address, loser_ieees)
|
||||
|
||||
# Two nodes on one canvas may now draw the survivor — that is exactly the
|
||||
# same-design duplicate the node repair collapses.
|
||||
await dedupe_nodes_by_device(db)
|
||||
await db.flush()
|
||||
|
||||
logger.info(
|
||||
"Inventory merge: %d row(s) folded into %s (%s)",
|
||||
len(losers), winner.id, winner.label or winner.friendly_name or winner.ip,
|
||||
)
|
||||
return {
|
||||
"merged": len(losers),
|
||||
"device_id": winner.id,
|
||||
"nodes_repointed": nodes.rowcount or 0,
|
||||
"documents_orphaned": orphaned,
|
||||
"views_extended": shown,
|
||||
}
|
||||
|
||||
|
||||
def _conflicting(left: InventoryDevice, right: InventoryDevice) -> bool:
|
||||
"""True when two rows carry addresses that say they are different machines.
|
||||
|
||||
A distinct non-blank IEEE on each is decisive: two Proxmox guests (the
|
||||
synthetic ``pve-{host}-{vmid}``) or two radios are never one device. A
|
||||
distinct MAC says the same for hardware. Either one vetoes an automatic
|
||||
merge — the user can still merge them by hand.
|
||||
"""
|
||||
if left.ieee_address and right.ieee_address and left.ieee_address.lower() != right.ieee_address.lower():
|
||||
return True
|
||||
return bool(left.mac and right.mac and left.mac != right.mac)
|
||||
|
||||
|
||||
def _pick_winner(group: list[InventoryDevice]) -> InventoryDevice:
|
||||
"""The row the others fold into.
|
||||
|
||||
An IEEE first: mesh links and the Proxmox host→guest graph are keyed on it,
|
||||
and the import matches on it before anything else, so the row holding one is
|
||||
the row every other writer will come back to. Then an approved row (it is
|
||||
the one already drawn), then the oldest.
|
||||
"""
|
||||
return sorted(
|
||||
group,
|
||||
key=lambda d: (
|
||||
0 if d.ieee_address else 1,
|
||||
0 if d.status == "approved" else 1,
|
||||
d.discovered_at,
|
||||
d.id,
|
||||
),
|
||||
)[0]
|
||||
|
||||
|
||||
def _auto_groups(rows: list[InventoryDevice]) -> list[list[InventoryDevice]]:
|
||||
"""Group rows a shared MAC proves to be one device.
|
||||
|
||||
The MAC and nothing else. A shared IP is not enough on its own — a re-used
|
||||
DHCP lease, two guests behind one NAT, or a container bridge address several
|
||||
guests report all make one address describe several machines — and the
|
||||
scanner already collapses what an IP does justify, under its own guard for
|
||||
rows that are drawn on a canvas. Labels and hostnames are ignored outright:
|
||||
two boxes are often called the same thing, and an automatic pass is not the
|
||||
place to decide they are one. Whatever is left over is what the manual merge
|
||||
is for.
|
||||
|
||||
A group is dropped whole when any pair inside it contradicts (see
|
||||
:func:`_conflicting`) rather than guessing which member does not belong.
|
||||
"""
|
||||
by_mac: dict[str, list[InventoryDevice]] = {}
|
||||
for row in rows:
|
||||
if row.mac:
|
||||
by_mac.setdefault(row.mac.lower(), []).append(row)
|
||||
|
||||
out = []
|
||||
for group in by_mac.values():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
pairs = [(a, b) for i, a in enumerate(group) for b in group[i + 1:]]
|
||||
if any(_conflicting(a, b) for a, b in pairs):
|
||||
logger.info(
|
||||
"Inventory reconcile: leaving %d row(s) sharing a MAC alone — "
|
||||
"their IEEE says they are different devices (%s)",
|
||||
len(group), ", ".join(row.id for row in group),
|
||||
)
|
||||
continue
|
||||
out.append(group)
|
||||
return out
|
||||
|
||||
|
||||
async def reconcile_duplicates(db: AsyncSession) -> int:
|
||||
"""Merge inventory rows a shared MAC proves to be one device. Does not commit.
|
||||
|
||||
Meant to run at the end of an import, where a row that was created blind may
|
||||
have just gained the address that identifies it. Returns the number of rows
|
||||
merged away.
|
||||
|
||||
Hidden rows are excluded on both sides: the user hid one deliberately, and
|
||||
folding it into a visible row would put it back in front of them, while
|
||||
folding a visible row into it would make a device they are using disappear.
|
||||
"""
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at, InventoryDevice.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
merged = 0
|
||||
for group in _auto_groups(list(rows)):
|
||||
winner = _pick_winner(group)
|
||||
result = await merge_devices(db, winner, [row for row in group if row is not winner])
|
||||
merged += int(result["merged"])
|
||||
return merged
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
|
||||
"""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
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Backlinks — the documents pointing at a given document.
|
||||
|
||||
A wiki-link is one-way in the body: `[[device:nas-01]]` says where to go, not
|
||||
where it came from. This walks every body once and inverts that.
|
||||
|
||||
The parsing and resolution rules **mirror `frontend/src/documentation/
|
||||
wikilinks.ts` exactly** — same prefixes, same fallback order, same
|
||||
case-insensitivity — the way `doc_template.service_url` mirrors
|
||||
`utils/serviceUrl.ts`. They are duplicated rather than shared because the
|
||||
forward direction has to resolve while the user types, with only what the
|
||||
browser already holds, and the reverse direction needs every body, which the
|
||||
browser does not hold: the list endpoint is metadata-only on purpose.
|
||||
|
||||
Change one side and change the other; `test_doc_backlinks.py` pins the rules.
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
_LINK = re.compile(r"\[\[([^\]]+)\]\]")
|
||||
_TARGETS = {"device", "doc", "node"}
|
||||
|
||||
# How much of the line the link sits on is worth showing next to it.
|
||||
_CONTEXT_CHARS = 160
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WikiLink:
|
||||
"""`[[device:nas-01|the NAS]]` → target=device, key=nas-01, label=the NAS."""
|
||||
|
||||
target: str
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Backlink:
|
||||
"""One document pointing at another, and the line it does it on."""
|
||||
|
||||
doc_id: str
|
||||
label: str
|
||||
context: str
|
||||
count: int
|
||||
|
||||
|
||||
def parse_link(inner: str) -> WikiLink | None:
|
||||
"""Parse the inside of a `[[…]]`. An unknown prefix is part of the key."""
|
||||
address, _, label_part = inner.partition("|")
|
||||
address = address.strip()
|
||||
label = label_part.strip()
|
||||
if not address:
|
||||
return None
|
||||
target = "doc"
|
||||
key = address
|
||||
prefix, sep, rest = address.partition(":")
|
||||
if sep and prefix.lower() in _TARGETS:
|
||||
target = prefix.lower()
|
||||
key = rest.strip()
|
||||
if not key:
|
||||
return None
|
||||
return WikiLink(target=target, key=key, label=label or key)
|
||||
|
||||
|
||||
def iter_links(body: str) -> Iterator[tuple[str, WikiLink]]:
|
||||
"""Every link in a body, with the line it was written on."""
|
||||
for line in (body or "").splitlines():
|
||||
for match in _LINK.finditer(line):
|
||||
link = parse_link(match.group(1))
|
||||
if link:
|
||||
yield line, link
|
||||
|
||||
|
||||
def device_label(device: Any) -> str:
|
||||
"""Mirrors `deviceLabel` in `documentation/tree.ts`."""
|
||||
return (
|
||||
device.label
|
||||
or device.friendly_name
|
||||
or device.hostname
|
||||
or device.ip
|
||||
or "Unnamed device"
|
||||
)
|
||||
|
||||
|
||||
def resolve(link: WikiLink, docs: list[Any], devices: list[Any]) -> str | None:
|
||||
"""The document a link points at, or None when nothing matches yet."""
|
||||
key = link.key.lower()
|
||||
if link.target == "device":
|
||||
device = next(
|
||||
(d for d in devices if d.id == link.key or device_label(d).lower() == key),
|
||||
None,
|
||||
)
|
||||
if device is None:
|
||||
return None
|
||||
return next((doc.id for doc in docs if doc.device_id == device.id), None)
|
||||
if link.target == "node":
|
||||
return next((doc.id for doc in docs if doc.node_id == link.key), None)
|
||||
# A bare or `doc:` link: id, then slug, then title.
|
||||
return (
|
||||
next((doc.id for doc in docs if doc.id == link.key), None)
|
||||
or next((doc.id for doc in docs if (doc.slug or "").lower() == key), None)
|
||||
or next((doc.id for doc in docs if (doc.title or "").lower() == key), None)
|
||||
)
|
||||
|
||||
|
||||
def _context(line: str, label: str) -> str:
|
||||
"""The line the link is on, trimmed to a readable window around it."""
|
||||
text = " ".join(line.split())
|
||||
if len(text) <= _CONTEXT_CHARS:
|
||||
return text
|
||||
at = text.find(label)
|
||||
if at < 0:
|
||||
return text[:_CONTEXT_CHARS].rstrip() + "…"
|
||||
start = max(0, at - _CONTEXT_CHARS // 2)
|
||||
end = min(len(text), start + _CONTEXT_CHARS)
|
||||
return ("…" if start else "") + text[start:end].strip() + ("…" if end < len(text) else "")
|
||||
|
||||
|
||||
def backlinks_for(target_id: str, docs: list[Any], devices: list[Any]) -> list[Backlink]:
|
||||
"""Which of `docs` link to `target_id`, in the order the tree lists them.
|
||||
|
||||
One entry per source document however many times it links, because the
|
||||
reader wants the documents, not the occurrences; `count` keeps the rest.
|
||||
A document linking to itself is not a backlink.
|
||||
"""
|
||||
found: dict[str, Backlink] = {}
|
||||
for doc in docs:
|
||||
# Every document is a resolution target, but only one carrying `[[`
|
||||
# can be a source — skipping the rest early keeps this one cheap pass.
|
||||
if doc.id == target_id or "[[" not in (doc.body or ""):
|
||||
continue
|
||||
for line, link in iter_links(doc.body or ""):
|
||||
if resolve(link, docs, devices) != target_id:
|
||||
continue
|
||||
existing = found.get(doc.id)
|
||||
if existing is None:
|
||||
found[doc.id] = Backlink(
|
||||
doc_id=doc.id,
|
||||
label=link.label,
|
||||
context=_context(line, link.label),
|
||||
count=1,
|
||||
)
|
||||
else:
|
||||
found[doc.id] = Backlink(
|
||||
doc_id=existing.doc_id,
|
||||
label=existing.label,
|
||||
context=existing.context,
|
||||
count=existing.count + 1,
|
||||
)
|
||||
return list(found.values())
|
||||
|
||||
|
||||
def has_device_link(docs: list[Any]) -> bool:
|
||||
"""Whether any body carries a `[[device:…]]`, so the devices load is skippable."""
|
||||
return any(
|
||||
link.target == "device" for doc in docs for _, link in iter_links(doc.body or "")
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Export the documentation space as a zip of `.md` files.
|
||||
|
||||
`Document.body` is already the whole markdown file, YAML frontmatter included,
|
||||
so exporting is a question of *where* each body lands, not what it contains —
|
||||
nothing here rewrites a byte the user wrote.
|
||||
|
||||
The archive mirrors what the sidebar shows. The Library tree becomes real
|
||||
directories, one per folder document; everything that is filed by a link rather
|
||||
than by hand (a device, a piece of canvas furniture, a whole canvas) lands in a
|
||||
flat directory named after its kind, because those are pivoted client-side and
|
||||
have no single tree to mirror.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Kinds that are placed by their link instead of by `parent_id`, and the
|
||||
# directory each one exports into.
|
||||
LINKED_DIRS = {"device": "devices", "node": "nodes", "design": "designs"}
|
||||
|
||||
# A folder carries an index body of its own, and a directory cannot hold one —
|
||||
# so it becomes a file inside the directory it opens.
|
||||
FOLDER_INDEX = "index"
|
||||
|
||||
_UNSAFE = re.compile(r"[^a-z0-9._-]+")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportDoc:
|
||||
"""The fields the layout needs, so the mapping stays testable without a DB."""
|
||||
|
||||
id: str
|
||||
kind: str
|
||||
title: str
|
||||
slug: str
|
||||
parent_id: str | None
|
||||
body: str
|
||||
|
||||
|
||||
def safe_segment(raw: str) -> str:
|
||||
"""One path segment that cannot escape the archive or collide with a device.
|
||||
|
||||
Slugs come from `slugify` and are already tame, but a body can be exported
|
||||
long after a title was renamed by hand, and `..` or a leading dot would be a
|
||||
zip-slip waiting for whichever tool unpacks this.
|
||||
"""
|
||||
cleaned = _UNSAFE.sub("-", (raw or "").strip().lower()).strip("-.")
|
||||
return cleaned or "untitled"
|
||||
|
||||
|
||||
def _ancestors(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> list[str]:
|
||||
"""The folder segments above `doc`, outermost first.
|
||||
|
||||
A parent chain that loops — which the move guards prevent, but a hand-edited
|
||||
database does not — stops at the first repeat rather than spinning.
|
||||
"""
|
||||
segments: list[str] = []
|
||||
seen = {doc.id}
|
||||
current = by_id.get(doc.parent_id or "")
|
||||
while current is not None and current.id not in seen:
|
||||
seen.add(current.id)
|
||||
segments.append(safe_segment(current.slug or current.title))
|
||||
current = by_id.get(current.parent_id or "")
|
||||
segments.reverse()
|
||||
return segments
|
||||
|
||||
|
||||
def _path_for(doc: ExportDoc, by_id: dict[str, ExportDoc]) -> str:
|
||||
name = safe_segment(doc.slug or doc.title)
|
||||
directory = LINKED_DIRS.get(doc.kind)
|
||||
if directory is not None:
|
||||
return f"{directory}/{name}.md"
|
||||
parts = _ancestors(doc, by_id)
|
||||
if doc.kind == "folder":
|
||||
# The folder's own body sits inside the directory it opens, so its
|
||||
# children are siblings of it rather than of the folder file.
|
||||
return "/".join([*parts, name, f"{FOLDER_INDEX}.md"])
|
||||
return "/".join([*parts, f"{name}.md"])
|
||||
|
||||
|
||||
def export_paths(docs: list[ExportDoc]) -> list[tuple[str, str]]:
|
||||
"""`(path, body)` for every document, in a stable order and never colliding.
|
||||
|
||||
Slugs are only unique among siblings, and a linked document is a sibling of
|
||||
the Library root while exporting into `devices/`, so two documents really can
|
||||
want the same path. The second one gets a numbered name rather than
|
||||
overwriting the first — a silently short export is the worst outcome here.
|
||||
"""
|
||||
by_id = {doc.id: doc for doc in docs}
|
||||
taken: set[str] = set()
|
||||
out: list[tuple[str, str]] = []
|
||||
for doc in sorted(docs, key=lambda d: (d.kind, d.title.lower(), d.id)):
|
||||
path = _path_for(doc, by_id)
|
||||
if path.lower() in taken:
|
||||
stem, _, extension = path.rpartition(".")
|
||||
suffix = 2
|
||||
while f"{stem}-{suffix}.{extension}".lower() in taken:
|
||||
suffix += 1
|
||||
path = f"{stem}-{suffix}.{extension}"
|
||||
taken.add(path.lower())
|
||||
out.append((path, doc.body or ""))
|
||||
return out
|
||||
|
||||
|
||||
def build_zip(docs: list[ExportDoc]) -> bytes:
|
||||
"""The whole documentation space as one deflated archive, built in memory.
|
||||
|
||||
A homelab's worth of markdown is kilobytes; streaming it would buy nothing
|
||||
and cost the caller a temporary file.
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path, body in export_paths(docs):
|
||||
archive.writestr(path, body)
|
||||
return buffer.getvalue()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Clear document links when the thing a document describes is deleted.
|
||||
|
||||
The schema declares `ON DELETE SET NULL` on `documents.device_id`, `node_id` and
|
||||
`design_id`, but SQLite runs here with foreign keys off (see
|
||||
`api/routes/scan.py::delete_pending`), so nothing enforces it. Without this the
|
||||
column would keep naming a row that no longer exists and the document would look
|
||||
linked while resolving to nothing.
|
||||
|
||||
Clearing rather than deleting is deliberate: the inventory outlives the canvas,
|
||||
and what the user wrote outlives both. The document survives as an orphan,
|
||||
keeping its denormalized `title`, and can be re-linked or filed into the Library.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document
|
||||
|
||||
|
||||
async def unlink_documents(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
device_ids: Sequence[str] | None = None,
|
||||
node_ids: Sequence[str] | None = None,
|
||||
design_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
"""Orphan every document pointing at any of the given rows.
|
||||
|
||||
Caller commits — this is meant to run inside the same transaction as the
|
||||
delete it accompanies.
|
||||
"""
|
||||
for column, ids in (
|
||||
(Document.device_id, device_ids),
|
||||
(Document.node_id, node_ids),
|
||||
(Document.design_id, design_ids),
|
||||
):
|
||||
if ids:
|
||||
await db.execute(update(Document).where(column.in_(list(ids))).values({column: None}))
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Full-text search over documents.
|
||||
|
||||
SQLite's FTS5 module is not guaranteed: the LXC and Docker images ship whatever
|
||||
SQLite the base distribution built, and `database.py` already avoids depending
|
||||
on JSON1 for the same reason. So the index is best-effort — every write keeps it
|
||||
up to date when it exists, and `search` falls back to LIKE when it does not. The
|
||||
endpoint reports which engine answered so the UI can drop snippet highlighting.
|
||||
|
||||
The index is maintained from Python rather than by triggers: the write paths are
|
||||
few, they are already inside a transaction, and a trigger would have to be kept
|
||||
in sync through `_try_migrate` forever.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Column positions inside documents_fts, for snippet().
|
||||
_BODY_COLUMN = 3
|
||||
|
||||
_available: bool | None = None
|
||||
|
||||
|
||||
def reset_availability_cache() -> None:
|
||||
"""Forget the probed FTS5 state. Tests swap databases between cases."""
|
||||
global _available
|
||||
_available = None
|
||||
|
||||
|
||||
async def fts_available(db: AsyncSession) -> bool:
|
||||
"""Whether this SQLite build has FTS5 and the index table exists."""
|
||||
global _available
|
||||
if _available is None:
|
||||
try:
|
||||
await db.execute(text("SELECT doc_id FROM documents_fts LIMIT 1"))
|
||||
_available = True
|
||||
except OperationalError:
|
||||
_available = False
|
||||
logger.info("FTS5 unavailable — document search falls back to LIKE")
|
||||
return _available
|
||||
|
||||
|
||||
def tags_text(tags: Any) -> str:
|
||||
"""Flatten a tag list into one indexable string."""
|
||||
if isinstance(tags, list):
|
||||
return " ".join(str(tag) for tag in tags)
|
||||
return ""
|
||||
|
||||
|
||||
async def index_document(db: AsyncSession, doc: Any) -> None:
|
||||
"""Re-index one document. Safe to call when FTS5 is missing."""
|
||||
if not await fts_available(db):
|
||||
return
|
||||
await unindex_document(db, doc.id)
|
||||
await db.execute(
|
||||
text("INSERT INTO documents_fts (doc_id, title, tags, body) VALUES (:i, :t, :g, :b)"),
|
||||
{"i": doc.id, "t": doc.title or "", "g": tags_text(doc.tags), "b": doc.body or ""},
|
||||
)
|
||||
|
||||
|
||||
async def unindex_document(db: AsyncSession, doc_id: str) -> None:
|
||||
if not await fts_available(db):
|
||||
return
|
||||
await db.execute(text("DELETE FROM documents_fts WHERE doc_id = :i"), {"i": doc_id})
|
||||
|
||||
|
||||
def build_match_query(raw: str) -> str:
|
||||
"""Turn user input into an FTS5 MATCH expression.
|
||||
|
||||
Every term is quoted, so punctuation a user types (`192.168.1.1`, `nas-01`,
|
||||
an unbalanced quote) is data rather than FTS5 syntax, and gets a prefix `*`
|
||||
so typing half a word still matches.
|
||||
"""
|
||||
terms = [term.replace('"', '""') for term in raw.split() if term.strip()]
|
||||
return " AND ".join(f'"{term}"*' for term in terms)
|
||||
|
||||
|
||||
async def search(db: AsyncSession, query: str, limit: int = 25) -> tuple[str, list[dict[str, Any]]]:
|
||||
"""Return `(engine, hits)`; engine is "fts5" or "like"."""
|
||||
query = query.strip()
|
||||
if not query:
|
||||
return ("fts5" if await fts_available(db) else "like", [])
|
||||
|
||||
if await fts_available(db):
|
||||
match = build_match_query(query)
|
||||
if not match:
|
||||
return ("fts5", [])
|
||||
try:
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT doc_id, "
|
||||
f"snippet(documents_fts, {_BODY_COLUMN}, '<<', '>>', '…', 14) AS snip, "
|
||||
"rank AS score "
|
||||
"FROM documents_fts WHERE documents_fts MATCH :q "
|
||||
"ORDER BY rank LIMIT :n"
|
||||
),
|
||||
{"q": match, "n": limit},
|
||||
)
|
||||
).fetchall()
|
||||
return ("fts5", [{"doc_id": r[0], "snippet": r[1], "score": r[2]} for r in rows])
|
||||
except OperationalError as exc:
|
||||
# A malformed MATCH must degrade, never 500.
|
||||
logger.debug("FTS query failed, falling back to LIKE: %s", exc)
|
||||
|
||||
like = f"%{query.lower()}%"
|
||||
rows = (
|
||||
await db.execute(
|
||||
text(
|
||||
"SELECT id, body FROM documents "
|
||||
"WHERE lower(title) LIKE :q OR lower(body) LIKE :q "
|
||||
"ORDER BY updated_at DESC LIMIT :n"
|
||||
),
|
||||
{"q": like, "n": limit},
|
||||
)
|
||||
).fetchall()
|
||||
return ("like", [{"doc_id": r[0], "snippet": _excerpt(r[1] or "", query), "score": None} for r in rows])
|
||||
|
||||
|
||||
def _excerpt(body: str, query: str, width: int = 120) -> str:
|
||||
"""A LIKE-mode stand-in for snippet(): the text around the first match."""
|
||||
at = body.lower().find(query.lower())
|
||||
if at < 0:
|
||||
return body[:width].strip()
|
||||
start = max(0, at - width // 2)
|
||||
end = min(len(body), at + len(query) + width // 2)
|
||||
return ("…" if start else "") + body[start:end].strip() + ("…" if end < len(body) else "")
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Markdown scaffolding for documents.
|
||||
|
||||
The header of a device document is generated **once**, when the document is
|
||||
created or migrated from the old `notes` field, and from then on it belongs to
|
||||
the user — nothing here ever rewrites a body that already exists. What keeps the
|
||||
document honest instead is `facts_snapshot`, which records the facts as they read
|
||||
at generation time so the UI can tell the user the device has moved on, and
|
||||
`render_block`, which lets them drop a freshly generated section back in from the
|
||||
editor's `/` menu whenever they want it.
|
||||
|
||||
Every section is emitted even when its data is empty, with an italic prompt in
|
||||
place of the value. A blank section is the document telling the user what has not
|
||||
been written yet; omitting it would hide the gap.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
# Sections whose data is structural rather than descriptive: they are dropped
|
||||
# entirely rather than printed empty, because an unracked device has no rack
|
||||
# position to leave blank.
|
||||
_EMPTY = "—"
|
||||
_PROMPT = "_…_"
|
||||
|
||||
TEMPLATE_DEVICE = "device"
|
||||
TEMPLATE_BLANK = "blank"
|
||||
TEMPLATE_RUNBOOK = "runbook"
|
||||
TEMPLATE_SERVICE = "service"
|
||||
TEMPLATE_NETWORK = "network"
|
||||
TEMPLATE_INCIDENT = "incident"
|
||||
TEMPLATE_PROCEDURE = "procedure"
|
||||
TEMPLATE_DECISION = "decision"
|
||||
TEMPLATE_ZONE = "zone"
|
||||
|
||||
# Frontmatter carries a version from the first release: documents round-trip to
|
||||
# disk through export/import, so the on-disk shape is a compatibility surface.
|
||||
FRONTMATTER_VERSION = 1
|
||||
|
||||
|
||||
def cell(value: Any) -> str:
|
||||
"""Escape a value for a markdown table cell.
|
||||
|
||||
Mirrors `frontend/src/utils/exportMarkdown.ts`: backslash first, then pipe,
|
||||
then collapse newlines so one field cannot break the row.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return _EMPTY
|
||||
text = str(value).replace("\\", "\\\\").replace("|", "\\|")
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def device_name(device: Any) -> str:
|
||||
for attr in ("label", "friendly_name", "hostname", "ip"):
|
||||
value = getattr(device, attr, None)
|
||||
if value:
|
||||
return str(value)
|
||||
return "device"
|
||||
|
||||
|
||||
def device_type(device: Any) -> str | None:
|
||||
return getattr(device, "type", None) or getattr(device, "suggested_type", None)
|
||||
|
||||
|
||||
def _fmt_date(value: Any) -> str:
|
||||
if isinstance(value, datetime):
|
||||
return value.date().isoformat()
|
||||
if isinstance(value, date):
|
||||
return value.isoformat()
|
||||
return _EMPTY
|
||||
|
||||
|
||||
def _sources(device: Any) -> str:
|
||||
sources = getattr(device, "discovery_sources", None) or []
|
||||
if not sources:
|
||||
single = getattr(device, "discovery_source", None)
|
||||
sources = [single] if single else []
|
||||
return ", ".join(str(s) for s in sources) if sources else _EMPTY
|
||||
|
||||
|
||||
def service_key(service: dict[str, Any]) -> str:
|
||||
"""The identity used everywhere else for a service: port|protocol|name."""
|
||||
port = service.get("port")
|
||||
protocol = service.get("protocol") or "tcp"
|
||||
name = (service.get("service_name") or "").lower()
|
||||
return f"{port if port is not None else ''}|{protocol}|{name}"
|
||||
|
||||
|
||||
# Ports that are definitely not HTTP. Mirrors NON_HTTP_PORTS in
|
||||
# `frontend/src/utils/serviceUrl.ts` — a service link printed in a document has
|
||||
# to be the same link the canvas offers, or the document is quietly wrong.
|
||||
_NON_HTTP_PORTS = frozenset(
|
||||
{
|
||||
21, 23, 25, 53, 110, 143, 389, 445, 465, 514, 587, 636, 993, 995,
|
||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def service_url(service: dict[str, Any], host: str | None) -> str | None:
|
||||
"""The browsable URL for a service, or None when there is not one.
|
||||
|
||||
Follows `getServiceUrl`: everything is HTTP unless the port says otherwise,
|
||||
a host override wins over the device's address and hides the scanned port
|
||||
(it usually names a reverse proxy), and UDP and SSH have no browser URL.
|
||||
"""
|
||||
override = (service.get("host") or "").strip()
|
||||
effective_host = override or host
|
||||
if not effective_host or service.get("protocol") == "udp":
|
||||
return None
|
||||
|
||||
scheme: str | None = None
|
||||
hostname = effective_host.split(",")[0].strip()
|
||||
for prefix in ("https://", "http://"):
|
||||
if hostname.startswith(prefix):
|
||||
scheme = prefix[:-3]
|
||||
hostname = hostname[len(prefix):].rstrip("/")
|
||||
break
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
port = service.get("port")
|
||||
if port == 22 or (port is not None and port in _NON_HTTP_PORTS):
|
||||
return None
|
||||
|
||||
name = (service.get("service_name") or "").lower()
|
||||
if scheme is None:
|
||||
secure = any(token in name for token in ("https", "ssl", "tls")) or port in (443, 8443)
|
||||
scheme = "https" if secure else "http"
|
||||
|
||||
# A host override points at the public name; the scanned port is internal.
|
||||
printed_port = "" if override else (f":{port}" if port is not None else "")
|
||||
path = (service.get("path") or "").strip()
|
||||
if path and not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return f"{scheme}://{hostname}{printed_port}{path}"
|
||||
|
||||
|
||||
# ── generated blocks ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def block_device_info(device: Any, **_: Any) -> str:
|
||||
check = getattr(device, "check_method", None)
|
||||
target = getattr(device, "check_target", None)
|
||||
check_cell = f"`{check}` → `{target}`" if check and target else (f"`{check}`" if check else _EMPTY)
|
||||
vendor = getattr(device, "vendor", None)
|
||||
model = getattr(device, "model", None)
|
||||
vendor_model = " / ".join(part for part in (vendor, model) if part) or _EMPTY
|
||||
rows = [
|
||||
("Type", device_type(device)),
|
||||
("Vendor / Model", vendor_model),
|
||||
("Hostname", getattr(device, "hostname", None)),
|
||||
("IP", getattr(device, "ip", None)),
|
||||
("MAC", getattr(device, "mac", None)),
|
||||
("IEEE", getattr(device, "ieee_address", None)),
|
||||
("OS", getattr(device, "os", None)),
|
||||
("First discovered", f"{_fmt_date(getattr(device, 'discovered_at', None))} ({_sources(device)})"),
|
||||
("Status check", check_cell),
|
||||
]
|
||||
lines = ["| | |", "|---|---|"]
|
||||
lines += [f"| {label} | {cell(value)} |" for label, value in rows]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# What the hardware table looks for in `properties`, in order of preference.
|
||||
# The scanner and the Proxmox import write these keys ("CPU Cores", "RAM",
|
||||
# "Disk"); a user typing their own is likely to reach for one of the synonyms.
|
||||
# Matched case-insensitively, so "ram" and "RAM" are the same key.
|
||||
_HARDWARE_KEYS: dict[str, tuple[str, ...]] = {
|
||||
"cpu_model": ("cpu model", "cpu", "processor"),
|
||||
"cpu_count": ("cpu cores", "cores", "vcpu", "vcpus", "cpu count"),
|
||||
"ram": ("ram", "memory", "mem"),
|
||||
"disk": ("disk", "storage", "disk size", "capacity"),
|
||||
}
|
||||
|
||||
|
||||
def property_map(device: Any) -> dict[str, str]:
|
||||
"""A device's properties as a lowercased key -> value lookup.
|
||||
|
||||
The first occurrence of a key wins, matching how the tables read: a device
|
||||
carrying two "RAM" rows is showing the first one.
|
||||
"""
|
||||
found: dict[str, str] = {}
|
||||
for entry in getattr(device, "properties", None) or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
key = entry.get("key")
|
||||
value = entry.get("value")
|
||||
if not key or value in (None, ""):
|
||||
continue
|
||||
found.setdefault(str(key).strip().lower(), str(value).strip())
|
||||
return found
|
||||
|
||||
|
||||
def _hardware_property(properties: dict[str, str], field: str) -> str | None:
|
||||
for candidate in _HARDWARE_KEYS[field]:
|
||||
if candidate in properties:
|
||||
return properties[candidate]
|
||||
return None
|
||||
|
||||
|
||||
def block_hardware(device: Any, **_: Any) -> str:
|
||||
"""CPU / RAM / Disk, read from `properties` first.
|
||||
|
||||
The `cpu_count` / `cpu_model` / `ram_gb` / `disk_gb` columns are barely
|
||||
written any more — the scanner, the Proxmox import and the inventory modal
|
||||
all put hardware in `properties` instead — so a document generated from the
|
||||
columns alone printed an empty table beside a properties table listing the
|
||||
very same facts. Properties win; the columns are the fallback for a row that
|
||||
still carries them.
|
||||
|
||||
A property value is taken verbatim: it is already formatted the way the user
|
||||
or the importer wrote it ("15.5 GB"), and guessing a unit onto a bare number
|
||||
would be inventing a fact.
|
||||
"""
|
||||
properties = property_map(device)
|
||||
|
||||
cpu_model = _hardware_property(properties, "cpu_model") or getattr(device, "cpu_model", None)
|
||||
cpu_count = _hardware_property(properties, "cpu_count") or getattr(device, "cpu_count", None)
|
||||
if cpu_model and cpu_count:
|
||||
cpu: Any = f"{cpu_model} ×{cpu_count}"
|
||||
else:
|
||||
cpu = cpu_model or (f"{cpu_count} cores" if cpu_count else None)
|
||||
|
||||
ram_gb = getattr(device, "ram_gb", None)
|
||||
ram = _hardware_property(properties, "ram") or (f"{ram_gb:g} GB" if ram_gb else None)
|
||||
disk_gb = getattr(device, "disk_gb", None)
|
||||
disk = _hardware_property(properties, "disk") or (f"{disk_gb:g} GB" if disk_gb else None)
|
||||
|
||||
return "\n".join(
|
||||
[
|
||||
"| CPU | RAM | Disk |",
|
||||
"|---|---|---|",
|
||||
f"| {cell(cpu)} | {cell(ram)} | {cell(disk)} |",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def block_services(device: Any, **_: Any) -> str:
|
||||
services = getattr(device, "services", None) or []
|
||||
if not services:
|
||||
return "_No service has been fingerprinted on this device yet._"
|
||||
host = getattr(device, "ip", None) or getattr(device, "hostname", None)
|
||||
sections: list[str] = []
|
||||
for service in services:
|
||||
if not isinstance(service, dict):
|
||||
continue
|
||||
name = service.get("service_name") or "Service"
|
||||
port = service.get("port")
|
||||
protocol = service.get("protocol") or "tcp"
|
||||
heading = f"### {name}" + (f" — `{port}/{protocol}`" if port else "")
|
||||
lines = [heading, ""]
|
||||
url = service_url(service, host)
|
||||
if url:
|
||||
lines.append(f"- **URL** — <{url}>")
|
||||
if service.get("category"):
|
||||
lines.append(f"- **Category** — {service['category']}")
|
||||
lines += [
|
||||
f"- **Purpose** — {_PROMPT}",
|
||||
f"- **Credentials** — {_PROMPT} _(where they are kept — never the secret itself)_",
|
||||
f"- **Depends on** — {_PROMPT}",
|
||||
]
|
||||
sections.append("\n".join(lines))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def block_properties(device: Any, **_: Any) -> str:
|
||||
properties = getattr(device, "properties", None) or []
|
||||
rows = [p for p in properties if isinstance(p, dict) and p.get("key")]
|
||||
if not rows:
|
||||
return ""
|
||||
lines = ["| Key | Value |", "|---|---|"]
|
||||
lines += [f"| {cell(p.get('key'))} | {cell(p.get('value'))} |" for p in rows]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def block_rack(device: Any, *, rack: dict[str, Any] | None = None, zone_label: str | None = None, **_: Any) -> str:
|
||||
parts: list[str] = []
|
||||
if rack and rack.get("name"):
|
||||
u_start = rack.get("u_start")
|
||||
u_height = rack.get("u_height") or 1
|
||||
span = rack.get("col_span")
|
||||
where = f"Rack **{rack['name']}**"
|
||||
if u_start:
|
||||
top = u_start + u_height - 1
|
||||
where += f", U{u_start}" + (f"–U{top}" if top != u_start else "")
|
||||
if span:
|
||||
where += ", full width" if span >= 12 else f", {span}/12 wide"
|
||||
parts.append(where + ".")
|
||||
if zone_label:
|
||||
parts.append(f"Zone **{zone_label}**.")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def block_network(device: Any, *, connections: list[str] | None = None, **_: Any) -> str:
|
||||
ip = getattr(device, "ip", None)
|
||||
subnet = _guess_subnet(ip)
|
||||
peers = ", ".join(f"`{peer}`" for peer in connections) if connections else _PROMPT
|
||||
return "\n".join(
|
||||
[
|
||||
f"- **Subnet / VLAN** — {subnet or _PROMPT}",
|
||||
f"- **Connected to** — {peers}",
|
||||
f"- **DNS names** — {_PROMPT}",
|
||||
f"- **Firewall / exposure** — {_PROMPT}",
|
||||
f"- **Reverse proxy** — {_PROMPT}",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _guess_subnet(ip: str | None) -> str | None:
|
||||
"""The /24 an IPv4 address sits in. Multi-IP fields keep the first token."""
|
||||
if not ip:
|
||||
return None
|
||||
first = ip.split(",")[0].strip()
|
||||
octets = first.split(".")
|
||||
if len(octets) != 4 or not all(o.isdigit() for o in octets):
|
||||
return None
|
||||
return f"{octets[0]}.{octets[1]}.{octets[2]}.0/24"
|
||||
|
||||
|
||||
BLOCKS: dict[str, Callable[..., str]] = {
|
||||
"device-info": block_device_info,
|
||||
"hardware": block_hardware,
|
||||
"services": block_services,
|
||||
"properties": block_properties,
|
||||
"rack": block_rack,
|
||||
"network": block_network,
|
||||
}
|
||||
|
||||
|
||||
def render_block(name: str, device: Any, **context: Any) -> str:
|
||||
render = BLOCKS.get(name)
|
||||
if render is None:
|
||||
raise ValueError(f"Unknown block: {name}")
|
||||
return render(device, **context)
|
||||
|
||||
|
||||
# ── the device document ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def facts_snapshot(device: Any) -> dict[str, Any]:
|
||||
"""The device facts a generated document depends on.
|
||||
|
||||
Compared against the live row to tell the user their document has drifted,
|
||||
so it holds the fields the template actually prints and nothing else.
|
||||
"""
|
||||
services = getattr(device, "services", None) or []
|
||||
return {
|
||||
"label": device_name(device),
|
||||
"type": device_type(device),
|
||||
"hostname": getattr(device, "hostname", None),
|
||||
"ip": getattr(device, "ip", None),
|
||||
"mac": getattr(device, "mac", None),
|
||||
"ieee_address": getattr(device, "ieee_address", None),
|
||||
"os": getattr(device, "os", None),
|
||||
"vendor": getattr(device, "vendor", None),
|
||||
"model": getattr(device, "model", None),
|
||||
"cpu_count": getattr(device, "cpu_count", None),
|
||||
"cpu_model": getattr(device, "cpu_model", None),
|
||||
"ram_gb": getattr(device, "ram_gb", None),
|
||||
"disk_gb": getattr(device, "disk_gb", None),
|
||||
# The properties table prints these, and the hardware table now reads
|
||||
# CPU / RAM / Disk out of them — so an edit to one is a real change to
|
||||
# what the document says. A plain dict, because the snapshot is compared
|
||||
# against its own JSON round-trip and a tuple would come back a list.
|
||||
"properties": {
|
||||
str(entry["key"]): str(entry.get("value") or "")
|
||||
for entry in reversed(getattr(device, "properties", None) or [])
|
||||
if isinstance(entry, dict) and entry.get("key")
|
||||
},
|
||||
"check_method": getattr(device, "check_method", None),
|
||||
"check_target": getattr(device, "check_target", None),
|
||||
"services": sorted(service_key(s) for s in services if isinstance(s, dict)),
|
||||
}
|
||||
|
||||
|
||||
def render_frontmatter(values: dict[str, Any]) -> str:
|
||||
"""A minimal YAML block.
|
||||
|
||||
Hand-rolled rather than `yaml.safe_dump` so key order is the order written
|
||||
here — the frontmatter is read by humans at the top of every document.
|
||||
|
||||
An empty list still prints. `tags: []` is the only advertisement tags have:
|
||||
dropped, the key never appears in any document and nobody learns it exists.
|
||||
An empty string is still dropped — `owner:` teaches nothing a reader of the
|
||||
template does not already see.
|
||||
"""
|
||||
lines = ["---"]
|
||||
for key, value in values.items():
|
||||
if value is None or value == "":
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
lines.append(f"{key}: [{', '.join(str(v) for v in value)}]")
|
||||
else:
|
||||
lines.append(f"{key}: {value}")
|
||||
lines.append("---")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_device_document(
|
||||
device: Any,
|
||||
*,
|
||||
zone_label: str | None = None,
|
||||
rack: dict[str, Any] | None = None,
|
||||
connections: list[str] | None = None,
|
||||
today: date | None = None,
|
||||
) -> str:
|
||||
"""The full skeleton for a device, old notes appended verbatim."""
|
||||
today = today or date.today()
|
||||
name = device_name(device)
|
||||
notes = (getattr(device, "notes", None) or "").strip()
|
||||
|
||||
front = render_frontmatter(
|
||||
{
|
||||
"homelable": FRONTMATTER_VERSION,
|
||||
"title": name,
|
||||
"device": getattr(device, "id", None),
|
||||
"type": device_type(device),
|
||||
"tags": [],
|
||||
"criticality": "medium",
|
||||
"owner": "",
|
||||
"review_every": "6m",
|
||||
"created": today.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
parts = [
|
||||
front,
|
||||
"",
|
||||
f"# {name}",
|
||||
"",
|
||||
"> _One line: what this device is for. Replace this quote._",
|
||||
"",
|
||||
"## Device Information",
|
||||
"",
|
||||
block_device_info(device),
|
||||
"",
|
||||
"### Hardware",
|
||||
"",
|
||||
block_hardware(device),
|
||||
"",
|
||||
]
|
||||
|
||||
location = block_rack(device, rack=rack, zone_label=zone_label)
|
||||
if location:
|
||||
parts += ["### Physical location", "", location, ""]
|
||||
|
||||
properties = block_properties(device)
|
||||
if properties:
|
||||
parts += ["### Properties", "", properties, ""]
|
||||
|
||||
parts += [
|
||||
"## Services",
|
||||
"",
|
||||
block_services(device),
|
||||
"",
|
||||
"## Network",
|
||||
"",
|
||||
block_network(device, connections=connections),
|
||||
"",
|
||||
"## Configuration",
|
||||
"",
|
||||
"_How it is set up, where the config lives, what is not default, and what",
|
||||
"would be needed to rebuild it from scratch._",
|
||||
"",
|
||||
"## Operations",
|
||||
"",
|
||||
"### Start / stop",
|
||||
"",
|
||||
_PROMPT,
|
||||
"",
|
||||
"### Backup",
|
||||
"",
|
||||
_PROMPT,
|
||||
"",
|
||||
"### Update procedure",
|
||||
"",
|
||||
_PROMPT,
|
||||
"",
|
||||
"### Monitoring and alerts",
|
||||
"",
|
||||
_PROMPT,
|
||||
"",
|
||||
"## Troubleshooting",
|
||||
"",
|
||||
"| Symptom | Likely cause | Fix |",
|
||||
"|---|---|---|",
|
||||
f"| {_PROMPT} | {_PROMPT} | {_PROMPT} |",
|
||||
"",
|
||||
"## Dependencies",
|
||||
"",
|
||||
f"- **Depends on** — {_PROMPT}",
|
||||
f"- **Used by** — {_PROMPT}",
|
||||
"",
|
||||
"## Changelog",
|
||||
"",
|
||||
"| Date | Change |",
|
||||
"|---|---|",
|
||||
f"| {today.isoformat()} | Document created |",
|
||||
"",
|
||||
"## Notes",
|
||||
"",
|
||||
]
|
||||
|
||||
if notes:
|
||||
parts += [
|
||||
f"<!-- Migrated verbatim from this device's Notes field on {today.isoformat()}. -->",
|
||||
"",
|
||||
notes,
|
||||
"",
|
||||
]
|
||||
else:
|
||||
parts += ["_Anything worth remembering about this device._", ""]
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ── library templates ───────────────────────────────────────────────────────
|
||||
|
||||
_LIBRARY_TEMPLATES: dict[str, tuple[str, list[str]]] = {
|
||||
TEMPLATE_BLANK: ("", []),
|
||||
TEMPLATE_RUNBOOK: (
|
||||
"runbook",
|
||||
["Trigger", "Preconditions", "Steps", "Verification", "Rollback", "Escalation"],
|
||||
),
|
||||
TEMPLATE_SERVICE: (
|
||||
"service",
|
||||
["What it does", "Endpoints", "Components", "Configuration", "Backup", "Failure modes"],
|
||||
),
|
||||
TEMPLATE_NETWORK: (
|
||||
"network",
|
||||
["Topology", "Subnets and VLANs", "DHCP and DNS", "Routing", "Firewall zones", "Remote access"],
|
||||
),
|
||||
TEMPLATE_INCIDENT: ("incident", ["Timeline", "Impact", "Root cause", "Fix", "What to change"]),
|
||||
TEMPLATE_PROCEDURE: ("procedure", ["Goal", "Prerequisites", "Steps", "Verification"]),
|
||||
TEMPLATE_DECISION: ("decision", ["Status", "Context", "Decision", "Consequences"]),
|
||||
TEMPLATE_ZONE: ("zone", ["Purpose", "Members", "Addressing", "Access", "Notes"]),
|
||||
}
|
||||
|
||||
TEMPLATE_IDS = frozenset({TEMPLATE_DEVICE, *_LIBRARY_TEMPLATES})
|
||||
|
||||
|
||||
def render_library_document(template_id: str, title: str, *, today: date | None = None) -> str:
|
||||
"""The skeleton for a page that is not about one device."""
|
||||
today = today or date.today()
|
||||
kind, headings = _LIBRARY_TEMPLATES.get(template_id, _LIBRARY_TEMPLATES[TEMPLATE_BLANK])
|
||||
front = render_frontmatter(
|
||||
{
|
||||
"homelable": FRONTMATTER_VERSION,
|
||||
"title": title,
|
||||
"template": kind or None,
|
||||
"tags": [],
|
||||
"created": today.isoformat(),
|
||||
}
|
||||
)
|
||||
parts = [front, "", f"# {title}", ""]
|
||||
for heading in headings:
|
||||
parts += [f"## {heading}", "", _PROMPT, ""]
|
||||
return "\n".join(parts)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Placement and parsing helpers for documents.
|
||||
|
||||
The Library is a real tree — a folder is a document with `kind='folder'`, so an
|
||||
empty folder exists and a folder can carry an index body. That makes three
|
||||
things this module owns: slugs that stay unique among siblings, a re-parent
|
||||
guard so the tree cannot be knotted into a cycle, and the subtree walk that
|
||||
`DELETE` needs because SQLite does not always enforce `ON DELETE CASCADE`
|
||||
(`api/routes/designs.py` unwinds its own deletes for the same reason).
|
||||
|
||||
It also parses the YAML frontmatter out of a body. The body is the source of
|
||||
truth — it is what exports to disk — and `documents.frontmatter` / `documents.tags`
|
||||
are only a cache so a listing can filter without reading every body.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document
|
||||
|
||||
# A document is addressed by id; the slug is for readable URLs and export
|
||||
# filenames, so it only has to be filesystem- and URL-safe.
|
||||
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
|
||||
# `[ \t]*`, not `\s*`: `\s` matches the newline the fence line ends with, and an
|
||||
# alternative that can be reached two ways makes the match quadratic on a body
|
||||
# that opens with `---` and never closes it — the shape a half-typed document
|
||||
# has for as long as the user is typing it.
|
||||
_FRONTMATTER = re.compile(r"\A---[ \t]*\n(.*?)\n---[ \t]*(?:\n|\Z)", re.DOTALL)
|
||||
|
||||
DOCUMENT_KINDS = frozenset({"device", "node", "design", "page", "folder"})
|
||||
|
||||
# Kinds that live in the Library tree and may therefore carry a parent.
|
||||
TREE_KINDS = frozenset({"page", "folder"})
|
||||
|
||||
REVISION_LIMIT = 50
|
||||
|
||||
|
||||
def slugify(title: str) -> str:
|
||||
slug = _SLUG_STRIP.sub("-", (title or "").strip().lower()).strip("-")
|
||||
return slug[:80] or "untitled"
|
||||
|
||||
|
||||
async def unique_slug(db: AsyncSession, title: str, *, parent_id: str | None, exclude_id: str | None = None) -> str:
|
||||
"""A slug no sibling is already using."""
|
||||
base = slugify(title)
|
||||
sibling = Document.parent_id.is_(None) if parent_id is None else Document.parent_id == parent_id
|
||||
query = select(Document.slug).where(sibling)
|
||||
if exclude_id is not None:
|
||||
query = query.where(Document.id != exclude_id)
|
||||
taken = {row for row in (await db.execute(query)).scalars().all()}
|
||||
if base not in taken:
|
||||
return base
|
||||
suffix = 2
|
||||
while f"{base}-{suffix}" in taken:
|
||||
suffix += 1
|
||||
return f"{base}-{suffix}"
|
||||
|
||||
|
||||
def parse_frontmatter(body: str) -> dict[str, Any]:
|
||||
"""The YAML block at the top of a body, or `{}`.
|
||||
|
||||
Malformed YAML is not an error the user should be blocked by — they are
|
||||
mid-edit. The cache simply stays empty until the block parses again.
|
||||
"""
|
||||
match = _FRONTMATTER.match(body or "")
|
||||
if not match:
|
||||
return {}
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return {str(key): _jsonable(value) for key, value in parsed.items()}
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
"""Coerce a parsed YAML value into something the JSON column can hold.
|
||||
|
||||
YAML resolves `created: 2026-09-05` to a `date` and `at: 10:30` to an int,
|
||||
neither of which survives `json.dumps`. The cache is only ever read back for
|
||||
filtering and display, so a string is the right shape — the body keeps the
|
||||
literal the user typed either way.
|
||||
"""
|
||||
if isinstance(value, str | int | float | bool) or value is None:
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return [_jsonable(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _jsonable(item) for key, item in value.items()}
|
||||
return str(value)
|
||||
|
||||
|
||||
def tags_from(frontmatter: dict[str, Any]) -> list[str]:
|
||||
raw = frontmatter.get("tags")
|
||||
if isinstance(raw, str):
|
||||
raw = [part.strip() for part in raw.split(",")]
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
return [str(tag).strip() for tag in raw if str(tag).strip()]
|
||||
|
||||
|
||||
async def is_ancestor(db: AsyncSession, candidate_id: str, of_id: str) -> bool:
|
||||
"""Whether `candidate_id` is `of_id` or sits above it.
|
||||
|
||||
Guards re-parenting: moving a folder inside its own subtree would orphan
|
||||
everything under it. Mirrors the `_is_ancestor` guard the MCP zone tools use.
|
||||
"""
|
||||
if candidate_id == of_id:
|
||||
return True
|
||||
seen: set[str] = set()
|
||||
current: str | None = of_id
|
||||
while current and current not in seen:
|
||||
seen.add(current)
|
||||
current = (await db.execute(select(Document.parent_id).where(Document.id == current))).scalar_one_or_none()
|
||||
if current == candidate_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def subtree_ids(db: AsyncSession, root_id: str) -> list[str]:
|
||||
"""`root_id` plus every document beneath it, deepest last."""
|
||||
collected = [root_id]
|
||||
frontier = [root_id]
|
||||
while frontier:
|
||||
children = (
|
||||
await db.execute(select(Document.id).where(Document.parent_id.in_(frontier)))
|
||||
).scalars().all()
|
||||
children = [child for child in children if child not in collected]
|
||||
if not children:
|
||||
break
|
||||
collected.extend(children)
|
||||
frontier = children
|
||||
return collected
|
||||
@@ -213,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:
|
||||
|
||||
@@ -16,12 +16,42 @@ 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.
|
||||
# Cap how much body we read when hunting for <title>. This is a cap on the
|
||||
# download itself, not just on what we scan: the body is streamed and the
|
||||
# connection dropped once we hold this much. Some endpoints stream without end
|
||||
# and send no Content-Length (bandwidth-test endpoints, MJPEG cameras), so
|
||||
# buffering a full response would OOM the backend.
|
||||
_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})
|
||||
#
|
||||
# 515 and 9100-9107 are raw printing (LPD / JetDirect). Those are not merely
|
||||
# uninteresting: a printer takes whatever bytes land on 9100 as a print job, so
|
||||
# a GET line makes it spit out a page of plaintext. Never send them anything.
|
||||
# nmap already ships `Exclude T:9100-9107` for the same reason, which is why
|
||||
# the -sV pass never triggered this — only our own probe did.
|
||||
_PRINTER_PORTS = frozenset({515, *range(9100, 9108)})
|
||||
_NON_HTTP_PORTS = frozenset({
|
||||
22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379,
|
||||
}) | _PRINTER_PORTS
|
||||
|
||||
|
||||
async def _read_capped(resp: httpx.Response) -> str:
|
||||
"""Read at most _MAX_BODY_BYTES of a streaming response, then stop."""
|
||||
chunks: list[bytes] = []
|
||||
size = 0
|
||||
async for chunk in resp.aiter_bytes():
|
||||
chunks.append(chunk)
|
||||
size += len(chunk)
|
||||
if size >= _MAX_BODY_BYTES:
|
||||
break
|
||||
raw = b"".join(chunks)[:_MAX_BODY_BYTES]
|
||||
encoding = resp.charset_encoding or "utf-8"
|
||||
try:
|
||||
return raw.decode(encoding, errors="replace")
|
||||
except LookupError:
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _extract_title(body: str) -> str | None:
|
||||
@@ -34,11 +64,11 @@ def _extract_title(body: str) -> str | None:
|
||||
|
||||
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
async with client.stream("GET", url, follow_redirects=True) as resp:
|
||||
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
||||
body = await _read_capped(resp)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""In-process registry for long-running import jobs that return a payload.
|
||||
|
||||
Pending imports already run in the background and report through ``scan_runs``.
|
||||
Canvas imports are different: the caller wants the fetched map *back* so it can
|
||||
drop it on the canvas, which kept the request open for the whole MQTT
|
||||
round-trip. On a large mesh that outlives any reverse proxy's read timeout
|
||||
(Cloudflare cuts at 100 s and returns a 524), so the browser never sees the
|
||||
result even though the fetch succeeded server-side.
|
||||
|
||||
The fix is to hand the client a job id immediately and let it poll. Results are
|
||||
transient — a canvas import is meaningless once the user has closed the modal —
|
||||
so they live in memory rather than the DB, and expire on a TTL. The backend runs
|
||||
a single uvicorn worker, the same assumption the scheduler and ``BackgroundTasks``
|
||||
already make.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
JobStatus = Literal["running", "done", "error"]
|
||||
|
||||
# How long a finished job stays readable. Long enough for a client that polls
|
||||
# slowly or reloads mid-import, short enough that a forgotten map is not held
|
||||
# for the life of the process.
|
||||
JOB_TTL_SECONDS = 900.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportJob:
|
||||
id: str
|
||||
status: JobStatus = "running"
|
||||
result: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
# HTTP status the synchronous route would have raised, so the client can
|
||||
# keep reacting to failures the way it always has.
|
||||
error_status: int | None = None
|
||||
created_at: float = field(default_factory=time.monotonic)
|
||||
finished_at: float | None = None
|
||||
|
||||
|
||||
_jobs: dict[str, ImportJob] = {}
|
||||
|
||||
|
||||
def _purge_expired(now: float) -> None:
|
||||
for job_id, job in list(_jobs.items()):
|
||||
if job.finished_at is not None and now - job.finished_at > JOB_TTL_SECONDS:
|
||||
del _jobs[job_id]
|
||||
|
||||
|
||||
def create_job() -> ImportJob:
|
||||
"""Register a new running job and return it."""
|
||||
now = time.monotonic()
|
||||
_purge_expired(now)
|
||||
job = ImportJob(id=str(uuid.uuid4()))
|
||||
_jobs[job.id] = job
|
||||
return job
|
||||
|
||||
|
||||
def get_job(job_id: str) -> ImportJob | None:
|
||||
"""Return a job, or None if it never existed or has expired."""
|
||||
_purge_expired(time.monotonic())
|
||||
return _jobs.get(job_id)
|
||||
|
||||
|
||||
def finish_job(job_id: str, result: dict[str, Any]) -> None:
|
||||
"""Mark a job done and attach its payload. No-op if it expired."""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "done"
|
||||
job.result = result
|
||||
job.finished_at = time.monotonic()
|
||||
|
||||
|
||||
def fail_job(job_id: str, error: str, status: int) -> None:
|
||||
"""Mark a job failed with a client-safe message. No-op if it expired."""
|
||||
job = _jobs.get(job_id)
|
||||
if job is None:
|
||||
return
|
||||
job.status = "error"
|
||||
job.error = error
|
||||
job.error_status = status
|
||||
job.finished_at = time.monotonic()
|
||||
|
||||
|
||||
def reset_jobs() -> None:
|
||||
"""Drop every job. For tests."""
|
||||
_jobs.clear()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,8 @@ import logging
|
||||
import ssl
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -21,7 +23,10 @@ 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)
|
||||
# Fallback wait for a gateway response. The effective value comes from
|
||||
# ``settings.mqtt_response_timeout`` (env MQTT_RESPONSE_TIMEOUT), read at call
|
||||
# time so an operator with a large mesh can raise it without a code change.
|
||||
_RESPONSE_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
||||
@@ -71,11 +76,14 @@ async def request_response(
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float = _RESPONSE_TIMEOUT,
|
||||
response_timeout: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
||||
JSON message received on ``response_topic`` as a dict.
|
||||
|
||||
``response_timeout`` defaults to ``settings.mqtt_response_timeout``
|
||||
(env ``MQTT_RESPONSE_TIMEOUT``, 300 s) — a large mesh can take minutes.
|
||||
|
||||
Raises:
|
||||
ImportError: if aiomqtt is not installed.
|
||||
TimeoutError: if no response arrives in time.
|
||||
@@ -88,6 +96,14 @@ async def request_response(
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
timeout = (
|
||||
float(settings.mqtt_response_timeout)
|
||||
if response_timeout is None
|
||||
else response_timeout
|
||||
)
|
||||
if timeout <= 0:
|
||||
timeout = _RESPONSE_TIMEOUT
|
||||
|
||||
response_payload: dict[str, Any] = {}
|
||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
||||
|
||||
@@ -122,12 +138,15 @@ async def request_response(
|
||||
raise ValueError(f"Malformed MQTT response: {exc}") from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=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
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for MQTT response after {timeout:g}s — "
|
||||
"raise MQTT_RESPONSE_TIMEOUT if your mesh is large"
|
||||
) from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty MQTT response received")
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
|
||||
``design_id`` (i.e. the same device placed twice on the *same* canvas).
|
||||
"""Duplicate canvas nodes: the same device drawn twice on the *same* canvas.
|
||||
|
||||
The same device legitimately appears on multiple canvases — placing a device on
|
||||
another design creates a second :class:`Node` for that IEEE by design (see the
|
||||
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
|
||||
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
|
||||
design are corrupt, and only those are collapsed here.
|
||||
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.
|
||||
|
||||
This module provides an idempotent, **loss-free** repair: per ``(ieee,
|
||||
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
|
||||
extras' data into it (properties, missing scalar fields, services), re-points
|
||||
every edge and ``parent_id`` reference onto the canonical node, then deletes the
|
||||
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
|
||||
dangling or redundant links remain.
|
||||
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 or_, select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Edge, Node
|
||||
from app.services.zigbee_service import merge_zigbee_properties
|
||||
from app.db.models import Document, Edge, Node, RackDevice
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
|
||||
|
||||
The canvas stores several addresses in one comma-separated string once a
|
||||
user adds e.g. an IPv6 address, so identity matching must compare per token.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
|
||||
async def find_duplicate_node(
|
||||
db: AsyncSession,
|
||||
design_id: str | None,
|
||||
@@ -45,153 +31,84 @@ async def find_duplicate_node(
|
||||
mac: str | None,
|
||||
ieee: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
|
||||
already sits on ``design_id``, else ``None``.
|
||||
"""Return conflict details if this device is already drawn on ``design_id``.
|
||||
|
||||
Scoped to a single design on purpose: the same device may legitimately
|
||||
appear on several canvases (one :class:`Node` per design). Only a second
|
||||
node for the same ieee/ip/mac on the *same* design is a duplicate — which
|
||||
the create/approve endpoints turn into a 409 so the UI can offer "go to
|
||||
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
|
||||
plain IP/ARP hosts.
|
||||
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.
|
||||
|
||||
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
|
||||
duplicates; this guard prevents new ones unless the user forces them.)
|
||||
The create/approve endpoints turn this into a 409 so the UI can offer "go to
|
||||
existing" vs "add duplicate anyway".
|
||||
"""
|
||||
ip_toks = _ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
conds.append(Node.ieee_address == ieee)
|
||||
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
|
||||
# before the IPv4), so narrow with a substring match then confirm per-token
|
||||
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
|
||||
for tok in ip_toks:
|
||||
conds.append(Node.ip.contains(tok))
|
||||
if mac:
|
||||
conds.append(Node.mac == mac)
|
||||
if not conds:
|
||||
device = await find_device_for(db, ip=ip, mac=mac, ieee=ieee)
|
||||
if device is None:
|
||||
return None
|
||||
candidates = (
|
||||
existing = (
|
||||
await db.execute(
|
||||
select(Node).where(Node.design_id == design_id, or_(*conds))
|
||||
select(Node)
|
||||
.where(Node.design_id == design_id, Node.device_id == device.id)
|
||||
.order_by(Node.created_at, Node.id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
|
||||
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
|
||||
def _matches(node: Node) -> tuple[str, str | None] | None:
|
||||
if ieee and node.ieee_address == ieee:
|
||||
return "ieee", node.ieee_address
|
||||
node_toks = set(_ip_tokens(node.ip))
|
||||
hit = next((t for t in ip_toks if t in node_toks), None)
|
||||
if hit is not None:
|
||||
return "ip", hit
|
||||
if mac and node.mac == mac:
|
||||
return "mac", mac
|
||||
).scalars().first()
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
existing = None
|
||||
matched: tuple[str, str | None] | None = None
|
||||
for node in candidates:
|
||||
m = _matches(node)
|
||||
if m is not None:
|
||||
existing, matched = node, m
|
||||
break
|
||||
if existing is None or matched is None:
|
||||
return None
|
||||
match, value = matched
|
||||
# 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": existing.label,
|
||||
"existing_label": device.label or existing.label,
|
||||
"match": match,
|
||||
"value": value,
|
||||
}
|
||||
|
||||
# Scalar Node fields worth carrying over from a duplicate when the canonical
|
||||
# node has no value. Positions, design, parent and identity fields are left on
|
||||
# the canonical node untouched (that's the row the user actually placed).
|
||||
_FILLABLE_FIELDS = (
|
||||
"hostname",
|
||||
"ip",
|
||||
"mac",
|
||||
"os",
|
||||
"check_method",
|
||||
"check_target",
|
||||
"notes",
|
||||
"cpu_count",
|
||||
"cpu_model",
|
||||
"ram_gb",
|
||||
"disk_gb",
|
||||
"custom_icon",
|
||||
"last_seen",
|
||||
"last_scan",
|
||||
)
|
||||
|
||||
async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
||||
"""Merge duplicate nodes drawing the same device on the same canvas.
|
||||
|
||||
def _merge_services(
|
||||
a: list[Any] | None, b: list[Any] | None
|
||||
) -> list[Any]:
|
||||
"""Union two service lists, de-duplicated, order-stable."""
|
||||
out: list[Any] = list(a or [])
|
||||
seen = {repr(s) for s in out}
|
||||
for s in b or []:
|
||||
if repr(s) not in seen:
|
||||
out.append(s)
|
||||
seen.add(repr(s))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
|
||||
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
|
||||
canonical.properties = merge_zigbee_properties(
|
||||
canonical.properties, dup.properties or []
|
||||
)
|
||||
canonical.services = _merge_services(canonical.services, dup.services)
|
||||
for field in _FILLABLE_FIELDS:
|
||||
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
|
||||
setattr(canonical, field, getattr(dup, field))
|
||||
# Prefer a human label over a bare IEEE/hex fallback.
|
||||
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
|
||||
canonical.label = dup.label
|
||||
|
||||
|
||||
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
|
||||
|
||||
Returns the number of nodes removed. Idempotent: a no-op when every
|
||||
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
|
||||
on *different* designs are left untouched (valid cross-canvas placement).
|
||||
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.ieee_address.is_not(None))
|
||||
.order_by(Node.ieee_address, Node.created_at, Node.id)
|
||||
.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.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
|
||||
groups.setdefault((node.device_id, node.design_id), []).append(node) # type: ignore[arg-type]
|
||||
|
||||
removed = 0
|
||||
for (ieee, _design), nodes in groups.items():
|
||||
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}
|
||||
|
||||
for dup in dups:
|
||||
_merge_into_canonical(canonical, dup)
|
||||
|
||||
# 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)
|
||||
)
|
||||
select(Edge).where(Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids))
|
||||
)
|
||||
).scalars().all()
|
||||
for edge in edges:
|
||||
@@ -200,19 +117,19 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
if edge.target in dup_ids:
|
||||
edge.target = canonical.id
|
||||
|
||||
# Re-point children whose parent was a duplicate.
|
||||
children = (
|
||||
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
||||
).scalars().all()
|
||||
for child in children:
|
||||
child.parent_id = canonical.id
|
||||
# The canonical node may itself have been nested under one of its
|
||||
# own duplicates. Re-pointing it here would make it its own parent,
|
||||
# which freezes it on the canvas (#370) — detach it instead. Mirrors
|
||||
# the self-loop edge deletion below.
|
||||
child.parent_id = None if child.id == canonical.id else canonical.id
|
||||
|
||||
# Collapse self-loops and now-redundant parallel edges.
|
||||
all_edges = (
|
||||
await db.execute(
|
||||
select(Edge).where(
|
||||
(Edge.source == canonical.id) | (Edge.target == canonical.id)
|
||||
)
|
||||
select(Edge).where((Edge.source == canonical.id) | (Edge.target == canonical.id))
|
||||
)
|
||||
).scalars().all()
|
||||
seen_pairs: set[tuple[str, str, str]] = set()
|
||||
@@ -226,14 +143,37 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
||||
continue
|
||||
seen_pairs.add(key)
|
||||
|
||||
# A rack mount reads live status through `node_id`, and a document may
|
||||
# describe the node itself. Both are `SET NULL` in the schema and SQLite
|
||||
# runs with foreign keys off here, so a duplicate deleted from under them
|
||||
# would leave the column naming a node that no longer exists.
|
||||
await db.execute(
|
||||
update(RackDevice).where(RackDevice.node_id.in_(dup_ids)).values(node_id=canonical.id)
|
||||
)
|
||||
own_doc = (
|
||||
await db.execute(select(Document.id).where(Document.node_id == canonical.id).limit(1))
|
||||
).scalar_one_or_none()
|
||||
doc_rows = (
|
||||
await db.execute(
|
||||
select(Document)
|
||||
.where(Document.node_id.in_(dup_ids))
|
||||
.order_by(Document.created_at, Document.id)
|
||||
)
|
||||
).scalars().all()
|
||||
for i, doc in enumerate(doc_rows):
|
||||
# `documents.node_id` is unique (partial index): only one document
|
||||
# can describe the survivor. The rest are orphaned, not deleted —
|
||||
# what the user wrote outlives the node it was attached to.
|
||||
doc.node_id = canonical.id if i == 0 and own_doc is None else None
|
||||
|
||||
await db.flush()
|
||||
for dup in dups:
|
||||
await db.delete(dup)
|
||||
removed += 1
|
||||
|
||||
logger.info(
|
||||
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
|
||||
ieee, len(dups), canonical.id,
|
||||
"Deduped device %s: merged %d duplicate node(s) into %s",
|
||||
device_id, len(dups), canonical.id,
|
||||
)
|
||||
|
||||
if removed:
|
||||
|
||||
@@ -93,24 +93,52 @@ def _guest_type_to_homelable(kind: str) -> str:
|
||||
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."""
|
||||
def _iface_ipv4(iface: Any) -> str | None:
|
||||
"""First non-loopback IPv4 of one guest-agent interface entry."""
|
||||
if not isinstance(iface, dict):
|
||||
return None
|
||||
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_qemu_ip(
|
||||
agent_payload: dict[str, Any] | None, mac: str | None = None
|
||||
) -> str | None:
|
||||
"""The guest's LAN IPv4 from a qemu guest-agent interfaces reply.
|
||||
|
||||
Prefer the interface whose ``hardware-address`` is the NIC MAC from the
|
||||
guest ``/config`` — the agent lists every interface, and on a guest running
|
||||
Docker/k8s the bridge (``cni0``, ``flannel``, ``docker0``) can come first.
|
||||
Those addresses repeat across guests, and a shared address is what let one
|
||||
guest's import land on another's inventory row. Fall back to the first
|
||||
non-loopback IPv4 when no MAC is known or none matches.
|
||||
"""
|
||||
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):
|
||||
wanted = normalize_mac(mac)
|
||||
if wanted:
|
||||
for iface in result:
|
||||
if not isinstance(iface, dict):
|
||||
continue
|
||||
if addr.get("ip-address-type") != "ipv4":
|
||||
if normalize_mac(iface.get("hardware-address")) != wanted:
|
||||
continue
|
||||
ip = addr.get("ip-address")
|
||||
if isinstance(ip, str) and ip and not ip.startswith("127."):
|
||||
ip = _iface_ipv4(iface)
|
||||
if ip:
|
||||
return ip
|
||||
for iface in result:
|
||||
ip = _iface_ipv4(iface)
|
||||
if ip:
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
@@ -374,7 +402,7 @@ async def _resolve_guest_net(
|
||||
data = await _get_json(
|
||||
client, f"/nodes/{host_name}/qemu/{vmid}/agent/network-get-interfaces"
|
||||
)
|
||||
ip = _extract_qemu_ip(data)
|
||||
ip = _extract_qemu_ip(data, mac)
|
||||
else:
|
||||
config = await _get_json(client, f"/nodes/{host_name}/lxc/{vmid}/config")
|
||||
ip = _extract_lxc_ip(config)
|
||||
|
||||
+479
-107
@@ -7,6 +7,7 @@ import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
@@ -15,10 +16,12 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.models import Node, PendingDevice, ScanRun
|
||||
from app.db.models import InventoryDevice, ScanRun
|
||||
from app.services.device_merge import merge_devices, reconcile_duplicates
|
||||
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.inventory_sync import ip_tokens, merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -61,8 +64,89 @@ def _valid_port_range(spec: str) -> bool:
|
||||
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."""
|
||||
# Every TCP port. Used by the per-device deep rescan, which trades minutes of
|
||||
# nmap time for a service list that no curated port list can promise.
|
||||
_FULL_PORTS = "1-65535"
|
||||
|
||||
# The deep rescan runs the full range in slices, one nmap call each, unioning
|
||||
# what they find. A slice that runs long costs only its own ports — where a
|
||||
# single 65535-port call that overruns costs everything (see _nmap_scan_single).
|
||||
# It also gives the run somewhere to notice a stop request, and somewhere to
|
||||
# give up when the budget is spent, without abandoning the ports already found.
|
||||
_DEEP_CHUNK_SIZE = 8192
|
||||
|
||||
|
||||
def _parse_port_spec(spec: str) -> list[tuple[int, int]]:
|
||||
"""Parse an nmap ``-p`` spec into sorted, merged ``(start, end)`` ranges.
|
||||
|
||||
Accepts what the user can type in the deep-scan dialog: ``80``,
|
||||
``8000-9000``, or a comma list of both. Returns ``[]`` for anything invalid
|
||||
— the caller turns that into a 422 rather than handing nmap a bad ``-p``.
|
||||
"""
|
||||
ranges: list[tuple[int, int]] = []
|
||||
for token in (t.strip() for t in spec.split(",")):
|
||||
if not token or not _valid_port_range(token):
|
||||
return []
|
||||
parts = [int(p) for p in token.split("-")]
|
||||
ranges.append((parts[0], parts[-1]))
|
||||
if not ranges:
|
||||
return []
|
||||
ranges.sort()
|
||||
merged = [ranges[0]]
|
||||
for start, end in ranges[1:]:
|
||||
last_start, last_end = merged[-1]
|
||||
if start <= last_end + 1:
|
||||
merged[-1] = (last_start, max(last_end, end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
return merged
|
||||
|
||||
|
||||
def _valid_port_spec(spec: str) -> bool:
|
||||
"""True when ``spec`` is a usable comma list of ports/ranges."""
|
||||
return bool(_parse_port_spec(spec))
|
||||
|
||||
|
||||
def _port_chunks(spec: str, size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice a port spec into nmap ``-p`` specs of at most ``size`` ports each.
|
||||
|
||||
Ranges are packed, not scanned one call per range: ``80,443`` is one chunk,
|
||||
not two, while ``1-65535`` becomes eight. Each chunk is a scan boundary —
|
||||
where a stop request lands and where the time budget is checked.
|
||||
"""
|
||||
chunks: list[str] = []
|
||||
current: list[str] = []
|
||||
budget = size
|
||||
for start, end in _parse_port_spec(spec):
|
||||
cursor = start
|
||||
while cursor <= end:
|
||||
take = min(budget, end - cursor + 1)
|
||||
stop = cursor + take - 1
|
||||
current.append(f"{cursor}-{stop}" if stop > cursor else str(cursor))
|
||||
budget -= take
|
||||
cursor = stop + 1
|
||||
if budget == 0:
|
||||
chunks.append(",".join(current))
|
||||
current = []
|
||||
budget = size
|
||||
if current:
|
||||
chunks.append(",".join(current))
|
||||
return chunks
|
||||
|
||||
|
||||
def _deep_port_chunks(size: int = _DEEP_CHUNK_SIZE) -> list[str]:
|
||||
"""Slice the whole TCP range into nmap ``-p`` specs of ``size`` ports."""
|
||||
return _port_chunks(_FULL_PORTS, size)
|
||||
|
||||
|
||||
def _build_port_spec(http_ranges: list[str] | None, full: bool = False) -> str:
|
||||
"""Combine the default port list with validated user ranges for nmap -p.
|
||||
|
||||
``full`` overrides everything with the whole TCP range — the deep rescan of
|
||||
a single device, where completeness beats speed.
|
||||
"""
|
||||
if full:
|
||||
return _FULL_PORTS
|
||||
if not http_ranges:
|
||||
return _EXTRA_PORTS
|
||||
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
||||
@@ -237,7 +321,9 @@ async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[
|
||||
return alive
|
||||
|
||||
|
||||
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
||||
def _nmap_scan_single(
|
||||
host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS, bounded: bool = False
|
||||
) -> 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.
|
||||
@@ -261,8 +347,26 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
|
||||
# -sT without root but being explicit avoids edge cases.
|
||||
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||
|
||||
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||
# --- Pass A: port discovery (no -sV) ---
|
||||
# Default timing for the range scan: its ports are authoritative and a
|
||||
# curated port list is fast either way.
|
||||
#
|
||||
# ``bounded`` is the deep rescan's timing, and carries NO --host-timeout on
|
||||
# purpose: nmap answers a host timeout with "Skipping host <ip> due to host
|
||||
# timeout" and discards *every* port it had already found, so a ceiling here
|
||||
# turns a slow scan into one that reports nothing. The caller bounds total
|
||||
# runtime by slicing the range instead.
|
||||
#
|
||||
# What costs the time is a host that drops packets: 8188 of 8192 ports
|
||||
# filtered, each waiting out its probe. Measured against such a host, the
|
||||
# retry pass is the whole cost — 8192 ports took 329s at --max-retries 1
|
||||
# and 164s at 0, finding the same ports. Capping the RTT changed nothing
|
||||
# (329s), so it is not set. Dropping retries risks missing a port that
|
||||
# loses its one probe; that trade belongs to the deep scan alone, and the
|
||||
# curated-port range scan keeps nmap's default retries.
|
||||
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
|
||||
if bounded:
|
||||
discovery_args += " --max-retries 0 --min-rate 2000"
|
||||
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
|
||||
nm_disc = nmap.PortScanner()
|
||||
try:
|
||||
@@ -469,45 +573,214 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||
]
|
||||
|
||||
|
||||
def _group_by_shared_ip(rows: list[InventoryDevice]) -> list[list[InventoryDevice]]:
|
||||
"""Group rows that share at least one address, transitively.
|
||||
|
||||
A row may hold several comma-separated addresses, so grouping is per token:
|
||||
``10.0.0.1`` and ``10.0.0.1, 10.0.1.1`` are the same device, and a row
|
||||
bridging two others (``A,B`` next to ``A`` and ``B``) puts all three in one
|
||||
group. Input order is preserved inside each group, so a caller ordering by
|
||||
``discovered_at`` still gets the oldest row first.
|
||||
"""
|
||||
# Union-find over row indices, keyed by the addresses they carry.
|
||||
parent = list(range(len(rows)))
|
||||
|
||||
def find(i: int) -> int:
|
||||
while parent[i] != i:
|
||||
parent[i] = parent[parent[i]]
|
||||
i = parent[i]
|
||||
return i
|
||||
|
||||
owner: dict[str, int] = {}
|
||||
for i, row in enumerate(rows):
|
||||
for tok in ip_tokens(row.ip):
|
||||
first = owner.setdefault(tok, i)
|
||||
root_a, root_b = find(first), find(i)
|
||||
if root_a != root_b:
|
||||
parent[max(root_a, root_b)] = min(root_a, root_b)
|
||||
|
||||
groups: dict[int, list[InventoryDevice]] = {}
|
||||
for i, row in enumerate(rows):
|
||||
groups.setdefault(find(i), []).append(row)
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def _collapse_targets(
|
||||
group: list[InventoryDevice],
|
||||
) -> tuple[InventoryDevice, list[InventoryDevice]]:
|
||||
"""The row a duplicate group folds into, and the rows safe to delete for it.
|
||||
|
||||
An ``approved`` row owns the canvas-link semantics — nodes and rack mounts
|
||||
point at it under ``ON DELETE SET NULL`` — so deleting one silently unlinks
|
||||
whatever drew it. With one approved row that row is the merge target and the
|
||||
rest collapse into it. With *two*, the group is two canvas-linked devices
|
||||
laying claim to one address, and a background scan is not the place to
|
||||
decide which of them loses its links: every approved row stands, and only
|
||||
the pending ones are collapsed. The pair then stays visible in the inventory
|
||||
for the user to sort out.
|
||||
"""
|
||||
approved = [row for row in group if row.status == "approved"]
|
||||
keep = approved[0] if approved else group[0]
|
||||
if len(approved) > 1:
|
||||
return keep, [row for row in group if row.status != "approved"]
|
||||
return keep, [row for row in group if row is not keep]
|
||||
|
||||
|
||||
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 carrying several addresses count as duplicates of any row holding one
|
||||
of them — that is what declaring the extra address is for.
|
||||
"""
|
||||
rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
select(InventoryDevice)
|
||||
.where(InventoryDevice.status != "hidden", InventoryDevice.ip.isnot(None))
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
by_ip: dict[str, list[PendingDevice]] = {}
|
||||
for row in rows:
|
||||
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
||||
continue
|
||||
by_ip.setdefault(row.ip, []).append(row)
|
||||
|
||||
deleted = 0
|
||||
for group in by_ip.values():
|
||||
for group in _group_by_shared_ip(list(rows)):
|
||||
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
|
||||
keep, dups = _collapse_targets(group)
|
||||
if dups:
|
||||
# Non-destructive, for the same reason as `process_host`: whatever
|
||||
# drew the duplicate follows it into `keep`.
|
||||
await merge_devices(db, keep, dups)
|
||||
deleted += len(dups)
|
||||
if deleted:
|
||||
await db.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
async def process_host(
|
||||
db: AsyncSession,
|
||||
host: dict[str, Any],
|
||||
*,
|
||||
hidden_ips: set[str],
|
||||
deep_scan: DeepScanOptions,
|
||||
discovery_source: str = "arp",
|
||||
) -> str:
|
||||
"""Fold one scanned host into ``device_inventory`` and commit.
|
||||
|
||||
Shared by the range scan and the single-device deep rescan, so both apply
|
||||
the same matching, merge and de-duplication rules.
|
||||
|
||||
Returns ``"skipped"`` (hidden by the user), ``"created"`` (a new inventory
|
||||
row) or ``"updated"`` (an existing row refreshed).
|
||||
"""
|
||||
ip = host["ip"]
|
||||
|
||||
# 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 "skipped"
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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.
|
||||
#
|
||||
# The IP side matches per token: a row may hold several comma-separated
|
||||
# addresses (a router's LAN and VLAN legs, a host and its VIP), declared by
|
||||
# the user precisely so this scan folds into it rather than minting a second
|
||||
# row every run. `contains` only narrows the query — the tokens are confirmed
|
||||
# below, because "10.0.0.4" is a substring of "10.0.0.40" and these rows are
|
||||
# deleted, not merely skipped.
|
||||
match_cond = [InventoryDevice.ip.contains(ip)]
|
||||
if norm_mac:
|
||||
match_cond.append(InventoryDevice.mac == norm_mac)
|
||||
candidates = (await db.execute(
|
||||
select(InventoryDevice)
|
||||
.where(or_(*match_cond), InventoryDevice.status != "hidden")
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
existing_rows = [
|
||||
row for row in candidates
|
||||
if ip in ip_tokens(row.ip) or (norm_mac is not None and row.mac == norm_mac)
|
||||
]
|
||||
|
||||
if existing_rows:
|
||||
# Prefer an approved row (it owns the canvas link semantics),
|
||||
# otherwise the oldest. Collapse any leftover duplicates created
|
||||
# by earlier scans — except a second approved row, which keeps its
|
||||
# own canvas links (see `_collapse_targets`).
|
||||
keep, dups = _collapse_targets(existing_rows)
|
||||
# Merge rather than delete: a duplicate row may be the one a canvas node
|
||||
# or a rack mount points at, and SQLite runs with foreign keys off here
|
||||
# so the declared `ON DELETE SET NULL` never fires. `merge_devices`
|
||||
# re-points every reference onto `keep` and unions the facts first.
|
||||
if dups:
|
||||
await merge_devices(db, keep, dups)
|
||||
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
|
||||
# Union, never replace. Since 3.3.0 the row is the only copy of
|
||||
# a device's services — every canvas drawing it reads this list
|
||||
# — so overwriting it with the fingerprint would delete services
|
||||
# the user added by hand, on every canvas at once. What a scan
|
||||
# finds is added; what it no longer sees stays. A service the
|
||||
# user does not want on a canvas is hidden by that node's view,
|
||||
# which is where "I deleted this" belongs.
|
||||
# discovered=: the fingerprint may add services and refresh what it
|
||||
# knows, but never repaints an icon or category the user chose.
|
||||
keep.services = merge_services(keep.services, services, discovered=True)
|
||||
# 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)
|
||||
outcome = "updated"
|
||||
else:
|
||||
db.add(InventoryDevice(
|
||||
ip=ip,
|
||||
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),
|
||||
))
|
||||
outcome = "created"
|
||||
|
||||
await db.commit()
|
||||
return outcome
|
||||
|
||||
|
||||
async def run_scan(
|
||||
ranges: list[str],
|
||||
db: AsyncSession,
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
) -> None:
|
||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||
"""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()
|
||||
@@ -527,9 +800,13 @@ async def run_scan(
|
||||
# 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()}
|
||||
# Per address, not per row: a hidden device holding several addresses is
|
||||
# hidden at every one of them.
|
||||
hidden_ips: set[str] = {
|
||||
tok for row in hidden_ips_result.fetchall() for tok in ip_tokens(row[0])
|
||||
}
|
||||
|
||||
# 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
|
||||
@@ -544,92 +821,17 @@ async def run_scan(
|
||||
|
||||
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
||||
nonlocal devices_found
|
||||
ip = host["ip"]
|
||||
|
||||
# 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)
|
||||
outcome = await process_host(
|
||||
db,
|
||||
host,
|
||||
hidden_ips=hidden_ips,
|
||||
deep_scan=deep_scan,
|
||||
discovery_source=discovery_source,
|
||||
)
|
||||
if outcome == "skipped":
|
||||
return
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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 = [PendingDevice.ip == ip]
|
||||
if norm_mac:
|
||||
match_cond.append(PendingDevice.mac == norm_mac)
|
||||
existing_rows = (await db.execute(
|
||||
select(PendingDevice)
|
||||
.where(or_(*match_cond), PendingDevice.status != "hidden")
|
||||
.order_by(PendingDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
if existing_rows:
|
||||
# Prefer an approved row (it owns the canvas link semantics),
|
||||
# otherwise the oldest. Collapse any leftover duplicates created
|
||||
# by earlier scans.
|
||||
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
||||
for dup in existing_rows:
|
||||
if dup is not keep:
|
||||
await db.delete(dup)
|
||||
keep.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.
|
||||
else:
|
||||
db.add(PendingDevice(
|
||||
ip=ip,
|
||||
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],
|
||||
))
|
||||
if outcome == "created":
|
||||
devices_found += 1
|
||||
|
||||
# Stamp last_scan on any canvas node that matches this device by IP
|
||||
# (or MAC, when known) so the inventory shows when the scanner last
|
||||
# observed it. Match both the normalized and raw MAC so a legacy
|
||||
# canvas node whose mac predates normalization still matches. Across
|
||||
# designs.
|
||||
node_match = [Node.ip == ip]
|
||||
for m in {norm_mac, host.get("mac")}:
|
||||
if m:
|
||||
node_match.append(Node.mac == m)
|
||||
matching_nodes = (await db.execute(
|
||||
select(Node).where(or_(*node_match))
|
||||
)).scalars().all()
|
||||
scanned_at = datetime.now(timezone.utc)
|
||||
for node in matching_nodes:
|
||||
node.last_scan = scanned_at
|
||||
|
||||
await db.commit()
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
# nmap scan per CIDR — results stream in progressively
|
||||
@@ -662,6 +864,14 @@ async def run_scan(
|
||||
else:
|
||||
mdns_task.cancel()
|
||||
|
||||
# A scan is where a row first learns its MAC, so it is also where two
|
||||
# rows can first be proven to be one device — fold those together before
|
||||
# reporting the run done.
|
||||
merged = await reconcile_duplicates(db)
|
||||
if merged:
|
||||
await db.commit()
|
||||
logger.info("Scan %s: merged %d duplicate inventory row(s)", run_id, merged)
|
||||
|
||||
# Mark scan as done or cancelled
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
@@ -683,3 +893,165 @@ async def run_scan(
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
async def run_device_scan(
|
||||
device_id: str,
|
||||
db: AsyncSession,
|
||||
run_id: str,
|
||||
deep_scan: DeepScanOptions | None = None,
|
||||
full_ports: bool = True,
|
||||
ports: str | None = None,
|
||||
) -> None:
|
||||
"""Deep-rescan one known device and refresh its inventory row.
|
||||
|
||||
No ping sweep and no mDNS: the device is already known, so the IP goes
|
||||
straight to the phase-2 port scan (``-Pn``). ``full_ports`` scans all 65535
|
||||
TCP ports, which is the point of the feature — a device added before the
|
||||
scanner knew a service, or listening on a port no curated list covers.
|
||||
Minutes, not seconds; the run is cancellable like any other.
|
||||
|
||||
``ports`` narrows that to a user-chosen spec (``80,443``, ``1-1024``) and
|
||||
wins over ``full_ports`` — the dialog prefills the full range, so a caller
|
||||
that passes something else means it.
|
||||
"""
|
||||
from app.api.routes.status import broadcast_scan_update
|
||||
|
||||
deep_scan = deep_scan or DeepScanOptions()
|
||||
port_spec = (
|
||||
ports if ports else _build_port_spec(deep_scan.http_ranges, full=full_ports)
|
||||
)
|
||||
|
||||
try:
|
||||
device = await db.get(InventoryDevice, device_id)
|
||||
if device is None or not device.ip:
|
||||
raise ValueError("Device has no IP to scan")
|
||||
|
||||
host: dict[str, Any] = {
|
||||
"ip": device.ip,
|
||||
"mac": device.mac,
|
||||
"hostname": device.hostname,
|
||||
"os": device.os,
|
||||
"open_ports": [],
|
||||
}
|
||||
# A rescan re-observes a device through the source it already has; it
|
||||
# is not a network discovery. Hardcoding "arp" here would tell a
|
||||
# Proxmox guest, a rack mount or a hand-added host that the network
|
||||
# scanner found it, and it would then answer that source filter.
|
||||
source = device.discovery_source or "arp"
|
||||
|
||||
# The full range goes out in slices so a stop request lands within one
|
||||
# slice instead of at the end, and so a spent budget keeps the ports
|
||||
# found so far. A curated port list is one call, as before.
|
||||
if ports:
|
||||
chunks = _port_chunks(ports)
|
||||
elif full_ports:
|
||||
chunks = _deep_port_chunks()
|
||||
else:
|
||||
chunks = [port_spec]
|
||||
# Retry-free timing pays for itself over thousands of ports on a host
|
||||
# that drops packets; over a handful it only costs accuracy.
|
||||
total_ports = sum(end - start + 1 for start, end in _parse_port_spec(port_spec))
|
||||
bounded = total_ports > _DEEP_CHUNK_SIZE
|
||||
deadline = time.monotonic() + settings.scanner_deep_host_timeout
|
||||
found: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, int]] = set()
|
||||
skipped = 0
|
||||
|
||||
for i, chunk in enumerate(chunks):
|
||||
if _is_cancelled(run_id):
|
||||
skipped = len(chunks) - i
|
||||
break
|
||||
if i and time.monotonic() > deadline:
|
||||
skipped = len(chunks) - i
|
||||
logger.warning(
|
||||
"[Deep scan] %s — budget of %ds spent, %d port range(s) not scanned",
|
||||
host["ip"], settings.scanner_deep_host_timeout, skipped,
|
||||
)
|
||||
break
|
||||
# bounded: retry-free timing, for a host that drops packets.
|
||||
scanned = await asyncio.to_thread(
|
||||
_nmap_scan_single, dict(host), chunk, bounded
|
||||
)
|
||||
for port in scanned.get("open_ports") or []:
|
||||
key = (port["protocol"], port["port"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
found.append(port)
|
||||
host["mac"] = host["mac"] or scanned.get("mac")
|
||||
host["os"] = scanned.get("os") or host["os"]
|
||||
|
||||
host["open_ports"] = found
|
||||
# A partial sweep still says what it saw; the row unions it in.
|
||||
partial = (
|
||||
f"Scanned {len(chunks) - skipped}/{len(chunks)} port ranges "
|
||||
f"({len(found)} open) — the rest was not reached"
|
||||
if skipped
|
||||
else None
|
||||
)
|
||||
|
||||
devices_found = 0
|
||||
if not _is_cancelled(run_id):
|
||||
# The row is being rescanned on the user's request, so it is never
|
||||
# "hidden" from itself — the route rejects hidden devices upfront.
|
||||
outcome = await process_host(
|
||||
db, host, hidden_ips=set(), deep_scan=deep_scan, discovery_source=source
|
||||
)
|
||||
if outcome == "created":
|
||||
devices_found = 1
|
||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
||||
run.devices_found = devices_found
|
||||
# Not a failure: a done run carrying an advisory, the way a Proxmox
|
||||
# import reports what it could not see. Never let a partial sweep
|
||||
# read as a complete one.
|
||||
if partial and run.status == "done":
|
||||
run.error = partial
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Device scan failed: %s", exc)
|
||||
await db.rollback()
|
||||
run = await db.get(ScanRun, run_id)
|
||||
if run:
|
||||
run.status = "error"
|
||||
run.error = str(exc)
|
||||
run.finished_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
finally:
|
||||
with _cancelled_lock:
|
||||
_cancelled_runs.discard(run_id)
|
||||
|
||||
|
||||
async def reconcile_orphan_runs(db: AsyncSession) -> int:
|
||||
"""
|
||||
Mark every scan run still flagged `running` at startup as errored.
|
||||
|
||||
Scans live on a background thread inside this process, so nothing can
|
||||
legitimately be `running` the moment we boot: any such row belongs to a
|
||||
previous process that died mid-scan (an OOM kill, `docker stop`, a crash).
|
||||
Left alone the row is immortal, and it also locks the target out — the
|
||||
trigger endpoints reject a new scan while one is `running` for the same
|
||||
range, so a single kill blocks re-scanning for good.
|
||||
|
||||
Returns the number of rows reconciled.
|
||||
"""
|
||||
orphans = (
|
||||
await db.execute(select(ScanRun).where(ScanRun.status == "running"))
|
||||
).scalars().all()
|
||||
if not orphans:
|
||||
return 0
|
||||
now = datetime.now(timezone.utc)
|
||||
for run in orphans:
|
||||
# "error" is the word run_scan and run_device_scan already write when
|
||||
# they fail; Scan History filters and colours that one.
|
||||
run.status = "error"
|
||||
run.error = "Interrupted: the backend restarted while this scan was running"
|
||||
run.finished_at = now
|
||||
await db.commit()
|
||||
logger.warning("Reconciled %d scan run(s) left running by a previous process", len(orphans))
|
||||
return len(orphans)
|
||||
|
||||
@@ -103,8 +103,16 @@ async def _ping(host: str) -> bool:
|
||||
|
||||
|
||||
async def _http_get(url: str, verify: bool = False) -> bool:
|
||||
async with httpx.AsyncClient(verify=verify, timeout=5) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
# Only the status line matters here. A plain .get() buffers the whole body
|
||||
# first, and some endpoints stream without end (bandwidth-test endpoints,
|
||||
# MJPEG cameras, log tails) — enough to OOM the backend. timeout=5 does not
|
||||
# save us: httpx applies it per network operation, not to the total time
|
||||
# spent draining a socket that keeps delivering data. stream() closes the
|
||||
# connection on exit without draining it.
|
||||
async with (
|
||||
httpx.AsyncClient(verify=verify, timeout=5) as client,
|
||||
client.stream("GET", url, follow_redirects=True) as resp,
|
||||
):
|
||||
return resp.status_code < 500
|
||||
|
||||
|
||||
@@ -126,10 +134,17 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
||||
# 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.
|
||||
#
|
||||
# 515 and 9100-9107 are raw printing (LPD / JetDirect), and they are here for a
|
||||
# stronger reason than the rest: a printer treats any bytes on 9100 as a print
|
||||
# job, so a GET line makes it print a page — every 60 s, for as long as the node
|
||||
# exists. Node Exporter also lives on 9100 and loses its status check because of
|
||||
# this; a grey dot is the cheaper mistake.
|
||||
_PRINTER_PORTS = frozenset({515, *range(9100, 9108)})
|
||||
_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,
|
||||
})
|
||||
}) | _PRINTER_PORTS
|
||||
_HTTPS_PORTS = frozenset({443, 8443})
|
||||
|
||||
|
||||
@@ -138,6 +153,41 @@ def _service_host(svc: dict[str, Any], host: str) -> str:
|
||||
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'.
|
||||
|
||||
@@ -146,6 +196,12 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
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":
|
||||
@@ -153,22 +209,28 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
|
||||
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
|
||||
is_web = port is not None or "http" in name or override_scheme is not None
|
||||
if not is_web:
|
||||
return "unknown"
|
||||
|
||||
try:
|
||||
scheme = "https" if (
|
||||
scheme = override_scheme or ("https" if (
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http"
|
||||
) else "http")
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
# A host override usually points at a reverse proxy, where the scanned
|
||||
# port is an internal detail that would break the public URL. Only a
|
||||
# port typed into the override itself is used.
|
||||
url_port = override_port if override else port
|
||||
url = f"{scheme}://{url_host}" + (f":{url_port}" if url_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)
|
||||
@@ -187,6 +249,13 @@ async def check_services(
|
||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
||||
async with sem:
|
||||
status = await check_service(svc, host)
|
||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
||||
# `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 []
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -19,7 +20,10 @@ except ImportError: # pragma: no cover
|
||||
_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)
|
||||
# Fallback wait for the networkmap response. The effective value comes from
|
||||
# ``settings.zigbee_networkmap_timeout`` (env ZIGBEE_NETWORKMAP_TIMEOUT), read at
|
||||
# call time so an operator with a large mesh can raise it without a code change.
|
||||
_NETWORKMAP_TIMEOUT = 300.0
|
||||
|
||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
||||
@@ -238,9 +242,14 @@ async def fetch_networkmap(
|
||||
password: str | None = None,
|
||||
tls: bool = False,
|
||||
tls_insecure: bool = False,
|
||||
response_timeout: float | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
||||
|
||||
``response_timeout`` defaults to ``settings.zigbee_networkmap_timeout``
|
||||
(env ``ZIGBEE_NETWORKMAP_TIMEOUT``, 300 s) — a 200+ device mesh can take
|
||||
minutes to answer.
|
||||
|
||||
Raises:
|
||||
TimeoutError: if the broker does not respond in time.
|
||||
ConnectionError: if the broker cannot be reached.
|
||||
@@ -252,6 +261,14 @@ async def fetch_networkmap(
|
||||
"Install it with: pip install aiomqtt"
|
||||
)
|
||||
|
||||
timeout = (
|
||||
float(settings.zigbee_networkmap_timeout)
|
||||
if response_timeout is None
|
||||
else response_timeout
|
||||
)
|
||||
if timeout <= 0:
|
||||
timeout = _NETWORKMAP_TIMEOUT
|
||||
|
||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
||||
|
||||
@@ -295,12 +312,15 @@ async def fetch_networkmap(
|
||||
) from exc
|
||||
return
|
||||
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
||||
await asyncio.wait_for(_wait_for_response(), timeout=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
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for networkmap response after {timeout:g}s — "
|
||||
"raise ZIGBEE_NETWORKMAP_TIMEOUT if your mesh is large"
|
||||
) from exc
|
||||
|
||||
if not response_payload:
|
||||
raise ValueError("Empty networkmap response received")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import importlib
|
||||
import os
|
||||
|
||||
# Must be set before any app import so pydantic-settings can resolve the required field.
|
||||
@@ -8,7 +9,7 @@ from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.db.database import Base, get_db
|
||||
from app.db.database import DOCUMENT_DDL, Base, _try_migrate, get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||
@@ -27,9 +28,38 @@ async def db_session():
|
||||
engine = create_async_engine(TEST_DB_URL)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
# Apply the same documents DDL init_db does, through the same helper, so
|
||||
# the suite matches boot. `_try_migrate` matters here: on a database
|
||||
# create_all has just built, every ALTER in that list is a no-op that
|
||||
# raises "duplicate column" — swallowing it is the point, not a leniency.
|
||||
for label, sql in DOCUMENT_DDL:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
# Background tasks open their own session through `AsyncSessionLocal` — the
|
||||
# request-scoped `get_db` override does not reach them. Left alone they would
|
||||
# run against the *real* configured database, so point the factory at the
|
||||
# test engine everywhere it was imported by name.
|
||||
patched = [
|
||||
importlib.import_module(name)
|
||||
for name in (
|
||||
"app.db.database",
|
||||
"app.api.routes.zigbee",
|
||||
"app.api.routes.zwave",
|
||||
"app.api.routes.proxmox",
|
||||
"app.api.routes.scan",
|
||||
)
|
||||
]
|
||||
originals = [
|
||||
(module, getattr(module, "AsyncSessionLocal", None)) for module in patched
|
||||
]
|
||||
for module, original in originals:
|
||||
if original is not None:
|
||||
module.AsyncSessionLocal = session_factory
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
for module, original in originals:
|
||||
if original is not None:
|
||||
module.AsyncSessionLocal = original
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
@@ -48,3 +78,13 @@ 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']}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_doc_search_probe():
|
||||
"""Forget whether FTS5 was available — each test gets a fresh database."""
|
||||
from app.services import doc_search
|
||||
|
||||
doc_search.reset_availability_cache()
|
||||
yield
|
||||
doc_search.reset_availability_cache()
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""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
|
||||
import shutil
|
||||
|
||||
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
|
||||
from app.core.config import APP_VERSION
|
||||
|
||||
# `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()
|
||||
|
||||
|
||||
async def test_upgrade_keeps_each_canvas_drawing_its_own_services(db_320):
|
||||
"""3.3.3: order and visibility become the node's, and the upgrade freezes them.
|
||||
|
||||
Two canvases drew the same host with different service lists, and a scan had
|
||||
already fingerprinted a third the user put on neither. All three converge on
|
||||
one inventory row — so each node must come out of the upgrade still drawing
|
||||
what it drew, with the scanner's guess hidden on both.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
|
||||
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
drawn = {}
|
||||
for node_id in ("n1", "n2"):
|
||||
node = await session.get(Node, node_id)
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
|
||||
# Seeded, not left to "show the whole row".
|
||||
assert node.display_view is not None
|
||||
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
|
||||
|
||||
# What a scan finds next lands on the row, and on no canvas.
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
device.services = [
|
||||
*device.services,
|
||||
{"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"},
|
||||
]
|
||||
await session.commit()
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [(s["service_name"], s.get("visible", True)) for s in payload["services"]] == [
|
||||
("ssh", True), ("Uptime Kuma", False),
|
||||
]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _wind_back_to_3_3_2(engine, extra_service: dict) -> None:
|
||||
"""A database that already took the 3.3.0 upgrade, leak included.
|
||||
|
||||
Both nodes point at one row holding the union of what each drew plus what a
|
||||
scan found, and neither has a view — which is every 3.3.0-3.3.2 install.
|
||||
"""
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql("UPDATE nodes SET display_view = NULL")
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
one = await session.get(InventoryDevice, node.device_id)
|
||||
other = await session.get(InventoryDevice, (await session.get(Node, "n2")).device_id)
|
||||
for device in (one, other):
|
||||
device.services = [
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
{"port": 443, "protocol": "tcp", "service_name": "https"},
|
||||
extra_service,
|
||||
]
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_a_database_already_on_3_3_recovers_its_layout_from_the_backup(db_320):
|
||||
"""The second upgrade path: 3.3.0-3.3.2, where the legacy columns are gone.
|
||||
|
||||
3.3.0 unioned every canvas' services onto one row, so the row can no longer
|
||||
say who drew what — but the backup taken before that migration still can.
|
||||
Recovering from it is the difference between the user getting their canvases
|
||||
back and getting each canvas showing every other canvas' services.
|
||||
"""
|
||||
_, engine = db_320
|
||||
await _build_320(engine)
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
both = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}, ' \
|
||||
'{"port": 443, "protocol": "tcp", "service_name": "https"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{ssh}' WHERE id = 'n1'")
|
||||
await conn.exec_driver_sql(f"UPDATE nodes SET services = '{both}' WHERE id = 'n2'")
|
||||
|
||||
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
drawn = {}
|
||||
for node_id in ("n1", "n2"):
|
||||
node = await session.get(Node, node_id)
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
drawn[node_id] = [s["service_name"] for s in payload["services"] if s.get("visible", True)]
|
||||
assert drawn == {"n1": ["ssh"], "n2": ["ssh", "https"]}
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_property_added_while_on_3_3_survives_the_recovery(db_320):
|
||||
"""The backup is 3.2.0-era and cannot know what the user added afterwards.
|
||||
|
||||
On 3.3.0-3.3.2 the row was the only place to add a property, and every
|
||||
canvas drew it. Recovering the view strictly from the backup would hide it
|
||||
on all of them at once — the "my properties disappeared" half of #347. The
|
||||
scanner's service find is still held back: only properties are appended.
|
||||
"""
|
||||
_, engine = db_320
|
||||
await _build_320(engine)
|
||||
rack = '[{"key": "Rack", "value": "A", "icon": null, "visible": true}]'
|
||||
ssh = '[{"port": 22, "protocol": "tcp", "service_name": "ssh"}]'
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
f"UPDATE nodes SET properties = '{rack}', services = '{ssh}' WHERE id = 'n1'"
|
||||
)
|
||||
|
||||
await database.init_db() # 3.2.0 -> 3.3.x, and the backup that predates it.
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
# What the user re-added by hand while running 3.3.x.
|
||||
device.properties = [
|
||||
*device.properties,
|
||||
{"key": "Ports", "value": "8", "icon": None, "visible": True},
|
||||
]
|
||||
await session.commit()
|
||||
|
||||
await database.init_db()
|
||||
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [(p["key"], p.get("visible", True)) for p in payload["properties"]] == [
|
||||
("Rack", True), ("Ports", True),
|
||||
]
|
||||
# And the scan's find is still off this canvas.
|
||||
assert [s["service_name"] for s in payload["services"] if s.get("visible", True)] == ["ssh"]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_without_a_usable_backup_the_row_is_the_seed(db_320):
|
||||
"""No backup to recover from: keep showing what the canvas shows today.
|
||||
|
||||
Nothing is taken away — the user simply keeps the merged list they have been
|
||||
looking at since 3.3.0, and only what the row gains *after* this boot is
|
||||
held back.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
await database.init_db()
|
||||
kuma = {"port": 3001, "protocol": "tcp", "service_name": "Uptime Kuma"}
|
||||
await _wind_back_to_3_3_2(engine, kuma)
|
||||
for backup in db_path.parent.glob(f"{db_path.name}.back-*"):
|
||||
backup.unlink()
|
||||
|
||||
await database.init_db()
|
||||
|
||||
from app.db.models import InventoryDevice, Node
|
||||
|
||||
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, node.device_id)
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [s["service_name"] for s in payload["services"]] == ["ssh", "https", "Uptime Kuma"]
|
||||
assert all(s.get("visible", True) for s in payload["services"])
|
||||
|
||||
# From here on the node is pinned: the next scan's find is held back.
|
||||
device.services = [*device.services, {"port": 5001, "protocol": "tcp", "service_name": "Synology DSM HTTPS"}]
|
||||
await session.commit()
|
||||
payload = inventory_sync.hydrated_node(node, device)
|
||||
assert [s["service_name"] for s in payload["services"] if not s.get("visible", True)] == [
|
||||
"Synology DSM HTTPS"
|
||||
]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_320):
|
||||
"""A 3.3.2 install has several backups; only some can answer.
|
||||
|
||||
`homelab.db.back-3.3.1` and `-3.3.2` were taken *after* the split and hold
|
||||
nothing per node; `-3.3.0` was taken before it. Reading the wrong one would
|
||||
either say nothing or resurrect a much older canvas.
|
||||
"""
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET services = "
|
||||
"'[{\"port\": 22, \"protocol\": \"tcp\", \"service_name\": \"ssh\"}]' WHERE id = 'n1'"
|
||||
)
|
||||
# Ancient: same shape, but a canvas the user has long since moved on from.
|
||||
old = db_path.parent / f"{db_path.name}.back-3.1.0"
|
||||
shutil.copy2(db_path, old)
|
||||
os.utime(old, (1, 1))
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET services = "
|
||||
"'[{\"port\": 443, \"protocol\": \"tcp\", \"service_name\": \"https\"}]' WHERE id = 'n1'"
|
||||
)
|
||||
pre_split = db_path.parent / f"{db_path.name}.back-3.3.0"
|
||||
shutil.copy2(db_path, pre_split)
|
||||
|
||||
await database.init_db() # drops the columns, and backs up under this version
|
||||
# Post-split backups: newer, and unable to say who drew what. On a real
|
||||
# install the current version's own backup is one of them — the columns went
|
||||
# in an earlier boot, not this one — so overwrite the one `init_db` took.
|
||||
for name in ("back-3.3.1", "back-3.3.2", f"back-{APP_VERSION}"):
|
||||
shutil.copy2(db_path, db_path.parent / f"{db_path.name}.{name}")
|
||||
|
||||
assert database._pre_split_backup() == pre_split
|
||||
assert database._views_from_backup()["n1"]["services"] == [
|
||||
{"port": 443, "protocol": "tcp", "service_name": "https"}
|
||||
]
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_furniture_description_survives_the_column_drop(db_320, monkeypatch):
|
||||
"""The rebuild copies a fixed column list — `description` has to be on it.
|
||||
|
||||
`_drop_legacy_node_columns` recreates `nodes` from `_NODE_COLUMNS_SQL` /
|
||||
`_NODE_KEPT`. A column missing from those two strings is silently lost on the
|
||||
boot that finally drops the legacy ones, which is exactly the boot a user who
|
||||
wrote a zone description in the meantime would not expect to lose it.
|
||||
"""
|
||||
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)
|
||||
|
||||
# First boot: the backfill cannot finish, so the legacy columns stay.
|
||||
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 "description" in cols
|
||||
assert "status" in cols # legacy still there
|
||||
# The user describes a piece of furniture.
|
||||
await conn.exec_driver_sql(
|
||||
"UPDATE nodes SET description = 'Behind the garage door.' WHERE id = 'n3'"
|
||||
)
|
||||
|
||||
# Second boot: the backfill succeeds and the legacy columns finally go.
|
||||
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
|
||||
assert "description" in cols
|
||||
kept = (await conn.exec_driver_sql(
|
||||
"SELECT description FROM nodes WHERE id = 'n3'"
|
||||
)).scalar()
|
||||
assert kept == "Behind the garage door."
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_the_description_column_is_added_once_and_is_idempotent(db_320):
|
||||
db_path, engine = db_320
|
||||
await _build_320(engine)
|
||||
|
||||
await database.init_db()
|
||||
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()
|
||||
assert [c[1] for c in info].count("description") == 1
|
||||
finally:
|
||||
await check.dispose()
|
||||
await engine.dispose()
|
||||
@@ -3,13 +3,13 @@ import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.models import PendingDevice
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pending_device(db_session):
|
||||
import uuid
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.100",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
@@ -39,10 +39,10 @@ async def mem_db():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def two_pending_devices(db_session):
|
||||
async def two_device_inventory(db_session):
|
||||
devices = []
|
||||
for i in range(2):
|
||||
d = PendingDevice(
|
||||
d = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=f"192.168.1.{10 + i}",
|
||||
mac=None,
|
||||
@@ -62,7 +62,7 @@ async def two_pending_devices(db_session):
|
||||
|
||||
@pytest.fixture
|
||||
async def zigbee_pending_device(db_session):
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip=None,
|
||||
mac=None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
|
||||
import uuid
|
||||
|
||||
from app.db.models import Design, Node, PendingDevice
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
|
||||
|
||||
async def _add_design(db_session, name: str) -> str:
|
||||
@@ -11,27 +11,58 @@ async def _add_design(db_session, name: str) -> str:
|
||||
return design.id
|
||||
|
||||
|
||||
def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
|
||||
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", status="online",
|
||||
ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
|
||||
design_id=design_id,
|
||||
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 Node, PendingDeviceLink
|
||||
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",
|
||||
status="unknown",
|
||||
ieee_address="0xCOORD",
|
||||
device_id=coord_device.id,
|
||||
)
|
||||
db_session.add(coord)
|
||||
|
||||
pending = PendingDevice(
|
||||
pending = InventoryDevice(
|
||||
ieee_address="0xR1",
|
||||
friendly_name="router_1",
|
||||
suggested_type="zigbee_router",
|
||||
@@ -42,7 +73,7 @@ async def _seed_zigbee_pending_pair(db_session):
|
||||
db_session.add(pending)
|
||||
|
||||
db_session.add(
|
||||
PendingDeviceLink(
|
||||
InventoryDeviceLink(
|
||||
source_ieee="0xCOORD",
|
||||
target_ieee="0xR1",
|
||||
discovery_source="zigbee",
|
||||
|
||||
@@ -5,14 +5,14 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Design, Node, PendingDevice
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
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".
|
||||
db_session.add(_node(None, ip="192.168.1.100"))
|
||||
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)
|
||||
@@ -57,16 +57,17 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
existing = Node(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
)
|
||||
db_session.add(existing)
|
||||
device = PendingDevice(
|
||||
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(
|
||||
@@ -86,7 +87,7 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
||||
|
||||
# No second node created; device stays pending until the user decides.
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 1
|
||||
|
||||
@@ -99,15 +100,15 @@ async def test_approve_device_force_creates_duplicate_ieee(
|
||||
design = Design(name="d1")
|
||||
db_session.add(design)
|
||||
await db_session.flush()
|
||||
db_session.add(Node(
|
||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
||||
services=[], design_id=design.id,
|
||||
))
|
||||
device = PendingDevice(
|
||||
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(
|
||||
@@ -120,7 +121,7 @@ async def test_approve_device_force_creates_duplicate_ieee(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
||||
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
|
||||
).scalars().all()
|
||||
assert len(nodes) == 2
|
||||
|
||||
@@ -133,8 +134,7 @@ async def test_approve_device_conflicts_on_existing_ip(
|
||||
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 = _node(design, ip="192.168.1.100")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -163,10 +163,8 @@ async def test_approve_device_conflicts_on_existing_mac(
|
||||
):
|
||||
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
|
||||
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
# 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(
|
||||
@@ -188,8 +186,7 @@ async def test_approve_device_conflicts_on_ip_in_comma_list(
|
||||
as. Exact-string matching missed it (issue #258); per-token matching catches
|
||||
the duplicate."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
existing = _node(design, ip="fe80::1, 192.168.1.100")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="fe80::1, 192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -212,7 +209,7 @@ async def test_approve_device_no_conflict_on_ip_substring(
|
||||
"""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")
|
||||
db_session.add(_node(design, ip="10.0.0.40"))
|
||||
await _node_for(db_session, design, ip="10.0.0.40")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -230,7 +227,7 @@ async def test_approve_device_force_creates_duplicate(
|
||||
):
|
||||
"""force=True (user confirmed) bypasses the guard and creates the node."""
|
||||
design = await _add_design(db_session, "Home")
|
||||
db_session.add(_node(design, ip="192.168.1.100"))
|
||||
await _node_for(db_session, design, ip="192.168.1.100")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -251,7 +248,7 @@ async def test_approve_device_allows_same_ip_on_other_design(
|
||||
"""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")
|
||||
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
|
||||
await _node_for(db_session, other, ip="192.168.1.100") # exists on a DIFFERENT design
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -273,13 +270,16 @@ async def test_approve_device_places_already_approved_on_another_design(
|
||||
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").
|
||||
db_session.add(_node(other, ieee="0x00158d0005292b83"))
|
||||
device = PendingDevice(
|
||||
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(
|
||||
@@ -292,7 +292,7 @@ async def test_approve_device_places_already_approved_on_another_design(
|
||||
# A node now exists on the target design too (one per canvas).
|
||||
nodes = (
|
||||
await db_session.execute(
|
||||
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
|
||||
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}
|
||||
@@ -399,8 +399,8 @@ async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||
|
||||
|
||||
@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]
|
||||
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()
|
||||
@@ -418,7 +418,7 @@ async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_p
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
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.
|
||||
@@ -426,7 +426,7 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
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_pending_devices]
|
||||
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")
|
||||
|
||||
@@ -458,13 +458,13 @@ async def test_bulk_approve_places_already_approved_device_on_another_design(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
client: AsyncClient, headers, db_session, two_pending_devices
|
||||
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_pending_devices]
|
||||
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).
|
||||
db_session.add(_node(design, ip="192.168.1.10"))
|
||||
await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -480,20 +480,21 @@ async def test_bulk_approve_skips_device_already_on_target_design(
|
||||
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(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
|
||||
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_pending_devices
|
||||
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_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
|
||||
await _node_for(db_session, design, ip="fe80::1, 192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -509,14 +510,13 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_reports_skipped_devices(
|
||||
client: AsyncClient, headers, db_session, two_pending_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_pending_devices]
|
||||
ids = [d.id for d in two_device_inventory]
|
||||
design = await _add_design(db_session, "Canvas")
|
||||
existing = _node(design, ip="192.168.1.10")
|
||||
db_session.add(existing)
|
||||
existing = await _node_for(db_session, design, ip="192.168.1.10")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
@@ -551,10 +551,14 @@ async def test_approve_device_copies_mac_to_node_and_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
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}
|
||||
]
|
||||
@@ -568,7 +572,7 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node as NodeModel
|
||||
device = PendingDevice(
|
||||
device = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.55",
|
||||
mac="11:22:33:44:55:66",
|
||||
@@ -587,28 +591,34 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
||||
).scalar_one()
|
||||
assert node.mac == "11:22:33:44:55:66"
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
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_pending_devices, db_session):
|
||||
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_pending_devices]
|
||||
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:
|
||||
if n.ip:
|
||||
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
||||
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
|
||||
@@ -624,12 +634,14 @@ async def test_approve_device_sets_default_check_method(client: AsyncClient, hea
|
||||
assert res.status_code == 200
|
||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
||||
assert node is not None
|
||||
assert node.check_method == "ping"
|
||||
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_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
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",
|
||||
@@ -645,15 +657,15 @@ async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers,
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
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_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
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()
|
||||
@@ -665,8 +677,8 @@ async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending
|
||||
|
||||
|
||||
@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]
|
||||
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)
|
||||
@@ -678,8 +690,8 @@ async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pen
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
||||
ids = [d.id for d in two_pending_devices]
|
||||
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
|
||||
|
||||
@@ -690,7 +702,7 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
|
||||
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 = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-2",
|
||||
friendly_name="Living Room Plug",
|
||||
@@ -713,11 +725,14 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
|
||||
assert res.json()["approved"] == 1
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
|
||||
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.
|
||||
assert node.status == "online"
|
||||
assert node.check_method == "none"
|
||||
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
||||
# 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,240 @@
|
||||
"""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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_edits_the_rack_faceplate(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
"""The front panel is a device fact, so the inventory can edit it too.
|
||||
|
||||
Same row the rack canvas writes on save — a plate edited here is the plate
|
||||
every rack mounting this device draws on its next load.
|
||||
"""
|
||||
device = await _seed(db_session, rack_faceplate_id="server-1u", rack_u_height=1)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}",
|
||||
headers=headers,
|
||||
json={
|
||||
"rack_faceplate_id": "server-2u-bays",
|
||||
"rack_u_height": 2,
|
||||
"rack_col_span": 12,
|
||||
"rack_color": "#ff6e00",
|
||||
"rack_ports": [
|
||||
{"id": "p1", "label": "eth0", "type": "rj45", "x": 0.2, "y": 0.6},
|
||||
# No id: nothing could ever cable it, so it never lands.
|
||||
{"label": "ghost", "type": "rj45", "x": 0.5, "y": 0.5},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert body["rack_faceplate_id"] == "server-2u-bays"
|
||||
assert body["rack_u_height"] == 2
|
||||
assert body["rack_color"] == "#ff6e00"
|
||||
assert [p["id"] for p in body["rack_ports"]] == ["p1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pending_leaves_an_unsent_faceplate_alone(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
device = await _seed(db_session, rack_faceplate_id="switch-24", rack_u_height=1)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"notes": "x"}
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["rack_faceplate_id"] == "switch-24"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
{"rack_u_height": 0},
|
||||
{"rack_u_height": 49},
|
||||
{"rack_col_span": 0},
|
||||
{"rack_col_span": 13},
|
||||
],
|
||||
)
|
||||
async def test_update_pending_rejects_an_impossible_plate(
|
||||
client: AsyncClient, headers, db_session, patch
|
||||
):
|
||||
device = await _seed(db_session)
|
||||
res = await client.patch(
|
||||
f"/api/v1/scan/pending/{device.id}", headers=headers, json=patch
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inventory_row_reports_no_plate_before_it_is_modelled(
|
||||
client: AsyncClient, headers, db_session
|
||||
):
|
||||
await _seed(db_session)
|
||||
rows = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
||||
assert rows[0]["rack_faceplate_id"] is None
|
||||
assert rows[0]["rack_ports"] == []
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Node, PendingDevice
|
||||
from app.db.models import InventoryDevice, Node
|
||||
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
|
||||
|
||||
|
||||
@@ -31,9 +31,13 @@ async def test_approve_zigbee_device_populates_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
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",
|
||||
@@ -56,14 +60,18 @@ async def test_bulk_approve_zigbee_populates_properties(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
||||
).scalar_one()
|
||||
keys = {p["key"]: p["value"] for p in node.properties}
|
||||
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 node.check_method == "none"
|
||||
assert device.check_method == "none"
|
||||
|
||||
|
||||
def test_build_mac_property_returns_hidden_row():
|
||||
@@ -133,9 +141,13 @@ async def test_approve_device_does_not_duplicate_mac_property(
|
||||
)
|
||||
assert res.status_code == 200
|
||||
node = (
|
||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
||||
).scalar_one()
|
||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
||||
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
|
||||
@@ -184,7 +196,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
"""Re-running the resolution does not create a second edge for the same pair."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
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": []}
|
||||
@@ -193,7 +205,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
# Simulate a second pending row + link between same coord and a new device,
|
||||
# but keep an existing edge in place to verify dedupe also handles
|
||||
# the swapped-direction case.
|
||||
new_pending = PendingDevice(
|
||||
new_pending = InventoryDevice(
|
||||
ieee_address="0xR1B",
|
||||
friendly_name="r1b",
|
||||
suggested_type="zigbee_router",
|
||||
@@ -202,7 +214,7 @@ async def test_approve_zigbee_skips_duplicate_edge(
|
||||
)
|
||||
db_session.add(new_pending)
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.post(
|
||||
@@ -220,16 +232,16 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
@@ -238,7 +250,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -258,7 +270,7 @@ async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert edges == []
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
assert len(links) == 1 # preserved for later resolution
|
||||
|
||||
|
||||
@@ -271,16 +283,16 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
be re-approved onto another canvas — it's topology, wiped only on reimport."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Edge, InventoryDevice, InventoryDeviceLink
|
||||
|
||||
a = PendingDevice(
|
||||
a = InventoryDevice(
|
||||
ieee_address="0xA",
|
||||
friendly_name="a",
|
||||
suggested_type="zigbee_router",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
b = PendingDevice(
|
||||
b = InventoryDevice(
|
||||
ieee_address="0xB",
|
||||
friendly_name="b",
|
||||
suggested_type="zigbee_enddevice",
|
||||
@@ -289,7 +301,7 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
)
|
||||
db_session.add_all([a, b])
|
||||
db_session.add(
|
||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
InventoryDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
@@ -300,14 +312,14 @@ async def test_approve_zigbee_resolves_link_after_second_approval(
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
assert len(edges) == 1
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
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 = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ieee_address="zwave-H-9",
|
||||
friendly_name="Door Sensor",
|
||||
@@ -328,9 +340,13 @@ async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_ses
|
||||
assert res.status_code == 200
|
||||
|
||||
node = (
|
||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
|
||||
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
|
||||
assert node.status == "online"
|
||||
assert node.check_method == "none"
|
||||
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
|
||||
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)
|
||||
|
||||
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import PendingDevice
|
||||
from tests.scan.helpers import _add_design, _node
|
||||
from app.db.models import InventoryDevice
|
||||
from tests.scan.helpers import _add_design, _node_for
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -55,7 +55,7 @@ async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session
|
||||
# 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")
|
||||
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
|
||||
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()
|
||||
@@ -67,7 +67,7 @@ async def test_canvas_count_correlates_by_mac(client, headers, db_session, pendi
|
||||
# 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")
|
||||
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
|
||||
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()
|
||||
@@ -79,8 +79,8 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
|
||||
# Same IP placed on two different canvases → canvas_count == 2.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
db_session.add(_node(d1, ip="192.168.1.100"))
|
||||
db_session.add(_node(d2, ip="192.168.1.100"))
|
||||
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)
|
||||
@@ -91,13 +91,13 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
||||
device = PendingDevice(
|
||||
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")
|
||||
db_session.add(_node(d1, ieee="0x00124b001"))
|
||||
await _node_for(db_session, d1, ieee="0x00124b001")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
@@ -120,10 +120,13 @@ async def test_pending_device_without_node_has_null_node_timestamps(client, head
|
||||
@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 = _node(d1, ip="192.168.1.100")
|
||||
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
||||
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
||||
db_session.add(node)
|
||||
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]
|
||||
@@ -139,18 +142,21 @@ async def test_node_timestamps_aggregate_across_matches(client, headers, db_sess
|
||||
# last_scan takes the NEWEST.
|
||||
d1 = await _add_design(db_session, "Home")
|
||||
d2 = await _add_design(db_session, "Lab")
|
||||
older = _node(d1, ip="192.168.1.100")
|
||||
older = await _node_for(db_session, d1, ip="192.168.1.100")
|
||||
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
||||
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
|
||||
newer = _node(d2, ip="192.168.1.100")
|
||||
newer = await _node_for(db_session, d2, ip="192.168.1.100")
|
||||
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
||||
newer.last_scan = datetime(2026, 6, 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
|
||||
assert data["node_last_scan"].startswith("2026-06-01") # newest
|
||||
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
|
||||
@@ -260,3 +266,302 @@ async def test_update_scan_config_persists_deep_scan(client: AsyncClient, header
|
||||
)
|
||||
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]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_requires_auth(client: AsyncClient, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/rescan")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_creates_run_for_that_ip(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["kind"] == "device"
|
||||
# One host, not the configured ranges — the scan targets this device only.
|
||||
assert data["ranges"] == ["192.168.1.100/32"]
|
||||
bg.assert_called_once()
|
||||
assert bg.call_args.args[1] == pending_device.id
|
||||
# Full range unless the caller says otherwise.
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_passes_the_requested_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""The dialog's range reaches the scanner verbatim."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " 80,443,8000-9000 "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] == "80,443,8000-9000"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_rejects_an_unusable_port_range(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
"""A bad spec is refused here, not handed to nmap."""
|
||||
for bad in ["0", "65536", "100-50", "80,", "http"]:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": bad},
|
||||
)
|
||||
assert res.status_code == 422, f"{bad}: {res.text}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_treats_a_blank_range_as_the_full_sweep(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock) as bg:
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan",
|
||||
headers=headers,
|
||||
json={"ports": " "},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert bg.call_args.args[4] is None
|
||||
assert bg.call_args.args[3] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.post(f"/api/v1/scan/pending/{uuid.uuid4()}/rescan", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_without_ip_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip=None, hostname="zigbee-lamp", status="pending")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
assert "IP" in res.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_hidden_409(client: AsyncClient, headers, db_session):
|
||||
device = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.77", status="hidden")
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(f"/api/v1/scan/pending/{device.id}/rescan", headers=headers)
|
||||
assert res.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_serialized_per_device(client: AsyncClient, headers, pending_device):
|
||||
"""A second rescan while the first still runs is refused, not duplicated."""
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
second = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 409
|
||||
assert "already running" in second.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rescan_device_allows_a_new_run_once_the_first_finished(
|
||||
client: AsyncClient, headers, pending_device
|
||||
):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
first = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
run_id = first.json()["id"]
|
||||
stopped = await client.post(f"/api/v1/scan/{run_id}/stop", headers=headers)
|
||||
assert stopped.status_code == 200
|
||||
again = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
assert again.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_returns_status(client: AsyncClient, headers, pending_device):
|
||||
with patch("app.api.routes.scan._background_device_scan", new_callable=AsyncMock):
|
||||
started = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/rescan", headers=headers
|
||||
)
|
||||
run_id = started.json()["id"]
|
||||
res = await client.get(f"/api/v1/scan/runs/{run_id}", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["id"] == run_id
|
||||
assert res.json()["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_run_unknown_id_404(client: AsyncClient, headers):
|
||||
res = await client.get(f"/api/v1/scan/runs/{uuid.uuid4()}", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
@@ -7,14 +7,24 @@ 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
|
||||
from app.db.models import InventoryDevice, Node, ScanRun
|
||||
from app.services.scanner import (
|
||||
_cancelled_runs,
|
||||
reconcile_orphan_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."""
|
||||
async def test_background_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""If run_scan() raises, the ScanRun must transition running → error and the
|
||||
session rollback path must execute without a follow-on exception.
|
||||
|
||||
"error" and not "failed": one condition, one name — it is what run_scan
|
||||
writes when it catches the failure itself, and the only one Scan History
|
||||
filters and colours.
|
||||
"""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -36,13 +46,13 @@ async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "failed"
|
||||
assert refreshed.status == "error"
|
||||
|
||||
|
||||
@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'."""
|
||||
must NOT overwrite that terminal status with 'error'."""
|
||||
from app.api.routes.scan import _background_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
@@ -118,7 +128,7 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
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")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
assert device is not None
|
||||
@@ -131,17 +141,7 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||
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."""
|
||||
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(
|
||||
stale = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -151,8 +151,17 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
suggested_type="generic",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(node)
|
||||
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())
|
||||
@@ -167,26 +176,30 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
|
||||
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")
|
||||
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 that already exists as a canvas Node still produces a pending
|
||||
device (no longer suppressed)."""
|
||||
node = Node(
|
||||
"""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",
|
||||
ip="192.168.1.50",
|
||||
status="online",
|
||||
services=[],
|
||||
device_id=existing.id,
|
||||
pos_x=0.0,
|
||||
pos_y=0.0,
|
||||
)
|
||||
db_session.add(node)
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
@@ -200,19 +213,23 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
||||
):
|
||||
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.status == "pending"
|
||||
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 = PendingDevice(
|
||||
approved = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
||||
os=None, services=[], suggested_type="server", status="approved",
|
||||
)
|
||||
@@ -228,7 +245,7 @@ async def test_run_scan_refreshes_approved_device_without_duplicating(db_session
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].status == "approved"
|
||||
@@ -240,7 +257,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
"""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(PendingDevice(
|
||||
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,
|
||||
))
|
||||
@@ -255,7 +272,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||
|
||||
rows = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
|
||||
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
|
||||
@@ -264,7 +281,7 @@ async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSessi
|
||||
@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(
|
||||
hidden = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -289,9 +306,9 @@ async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
||||
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",
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.ip == "192.168.1.50",
|
||||
InventoryDevice.status == "pending",
|
||||
)
|
||||
)
|
||||
assert result.scalar_one_or_none() is None
|
||||
@@ -395,7 +412,7 @@ async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: Asy
|
||||
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(
|
||||
existing = InventoryDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.50",
|
||||
mac=None,
|
||||
@@ -421,7 +438,7 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
|
||||
# Should still be only one device
|
||||
result = await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
assert len(devices) == 1
|
||||
@@ -429,3 +446,128 @@ async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession
|
||||
# Services and hostname should be updated
|
||||
assert device.hostname == "myhost.lan"
|
||||
assert any(s["port"] == 8096 for s in device.services)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_device_scan_marks_run_errored_on_exception(mem_db):
|
||||
"""Same word as the network scan — a device run that blows up reads alike."""
|
||||
from app.api.routes.scan import _background_device_scan
|
||||
|
||||
async with mem_db() as session:
|
||||
run = ScanRun(status="running", kind="device", ranges=["10.0.0.5/32"])
|
||||
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_device_scan",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
):
|
||||
await _background_device_scan(run_id, "d1")
|
||||
|
||||
async with mem_db() as session:
|
||||
refreshed = await session.get(ScanRun, run_id)
|
||||
assert refreshed is not None
|
||||
assert refreshed.status == "error"
|
||||
|
||||
|
||||
# --- reconcile_orphan_runs (issue #374) --------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_marks_running_as_error(mem_db):
|
||||
"""A run left "running" by a process that died mid-scan is immortal, and it
|
||||
also locks its range out of future scans. Startup must reconcile it."""
|
||||
async with mem_db() as session:
|
||||
orphan = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
||||
session.add(orphan)
|
||||
await session.commit()
|
||||
orphan_id = orphan.id
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 1
|
||||
|
||||
reconciled = await session.get(ScanRun, orphan_id)
|
||||
# "error", not "failed" — one condition, one name, and the only status
|
||||
# Scan History filters and colours.
|
||||
assert reconciled.status == "error"
|
||||
assert reconciled.finished_at is not None
|
||||
assert "restarted" in reconciled.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_leaves_finished_runs_alone(mem_db):
|
||||
"""Only "running" is orphaned. Terminal rows must not be rewritten."""
|
||||
async with mem_db() as session:
|
||||
done = ScanRun(status="done", ranges=["10.0.0.0/24"], devices_found=7)
|
||||
cancelled = ScanRun(status="cancelled", ranges=["10.0.1.0/24"])
|
||||
errored = ScanRun(status="error", ranges=["10.0.2.0/24"], error="nmap missing")
|
||||
session.add_all([done, cancelled, errored])
|
||||
await session.commit()
|
||||
ids = (done.id, cancelled.id, errored.id)
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 0
|
||||
|
||||
for run_id, expected in zip(ids, ("done", "cancelled", "error"), strict=True):
|
||||
assert (await session.get(ScanRun, run_id)).status == expected
|
||||
# The pre-existing error message must survive untouched.
|
||||
assert (await session.get(ScanRun, ids[2])).error == "nmap missing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_handles_several_and_an_empty_table(mem_db):
|
||||
async with mem_db() as session:
|
||||
# Nothing to do on a fresh database.
|
||||
assert await reconcile_orphan_runs(session) == 0
|
||||
|
||||
session.add_all([
|
||||
ScanRun(status="running", ranges=["10.0.0.0/24"]),
|
||||
ScanRun(status="running", kind="device", ranges=["10.0.0.5"]),
|
||||
ScanRun(status="done", ranges=["10.0.1.0/24"]),
|
||||
])
|
||||
await session.commit()
|
||||
|
||||
assert await reconcile_orphan_runs(session) == 2
|
||||
remaining = (
|
||||
await session.execute(select(ScanRun).where(ScanRun.status == "running"))
|
||||
).scalars().all()
|
||||
assert remaining == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_orphan_runs_unblocks_a_new_scan(mem_db):
|
||||
"""The point of the reconcile: the trigger endpoints reject a scan while one
|
||||
is "running" for the same target, so an orphan blocks that range for good."""
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(status="running", kind="device", ranges=["10.0.0.5"]))
|
||||
await session.commit()
|
||||
|
||||
await reconcile_orphan_runs(session)
|
||||
|
||||
blocking = (
|
||||
await session.execute(
|
||||
select(ScanRun).where(
|
||||
ScanRun.status == "running", ScanRun.kind == "device"
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert not any("10.0.0.5" in (r.ranges or []) for r in blocking)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lifespan_reconciles_orphan_runs_at_startup():
|
||||
"""The reconcile is worthless unless startup actually calls it."""
|
||||
from app.main import app as fastapi_app
|
||||
from app.main import lifespan
|
||||
|
||||
with patch("app.main.init_db", new=AsyncMock()), \
|
||||
patch("app.main.start_scheduler"), \
|
||||
patch("app.main.stop_scheduler"), \
|
||||
patch("app.main.reconcile_orphan_runs", new=AsyncMock()) as reconcile:
|
||||
async with lifespan(fastapi_app):
|
||||
pass
|
||||
|
||||
reconcile.assert_awaited_once()
|
||||
|
||||
@@ -272,6 +272,40 @@ async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_s
|
||||
assert protected.status_code == 200
|
||||
|
||||
|
||||
async def test_oidc_callback_returns_to_the_app_base_path(client: AsyncClient, oidc_settings):
|
||||
"""The SPA can be mounted under a prefix — the post-login redirect follows it."""
|
||||
oidc_settings.oidc_redirect_uri = "http://test/homelab/api/v1/auth/oidc/callback"
|
||||
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"] == "/homelab/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("redirect_uri", "expected"),
|
||||
[
|
||||
("", "/"),
|
||||
("https://home.example/api/v1/auth/oidc/callback", "/"),
|
||||
("https://home.example/homelab/api/v1/auth/oidc/callback", "/homelab/"),
|
||||
("https://home.example/apps/lab/api/v1/auth/oidc/callback", "/apps/lab/"),
|
||||
("/api/v1/auth/oidc/callback", "/"),
|
||||
("https://home.example/somewhere/else", "/"),
|
||||
],
|
||||
)
|
||||
def test_app_base_path_of(redirect_uri: str, expected: str):
|
||||
from app.core.config import app_base_path_of
|
||||
|
||||
assert app_base_path_of(redirect_uri) == expected
|
||||
|
||||
|
||||
@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}
|
||||
@@ -336,6 +370,84 @@ async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: Asy
|
||||
assert (await client.get("/api/v1/auth/me")).status_code == 401
|
||||
|
||||
|
||||
async def test_oidc_mutation_allows_app_origin_missing_from_cors_origins(
|
||||
client: AsyncClient, oidc_settings
|
||||
):
|
||||
"""Regression for #356: a same-origin reverse-proxy deploy needs no CORS, so
|
||||
CORS_ORIGINS is left at its localhost default while the browser still sends
|
||||
Origin — every POST used to 403."""
|
||||
oidc_settings.cors_origins = ["http://localhost:5173", "http://localhost:3000"]
|
||||
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"]
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "From OIDC", "design_type": "network"},
|
||||
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
|
||||
# The fallback widens the allowlist by exactly one origin, nothing more.
|
||||
evil = await client.post(
|
||||
"/api/v1/designs",
|
||||
json={"name": "Nope", "design_type": "network"},
|
||||
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
|
||||
)
|
||||
assert evil.status_code == 403
|
||||
|
||||
|
||||
def test_origin_is_allowed_falls_back_to_the_oidc_redirect_origin(oidc_settings):
|
||||
from app.core.security import origin_is_allowed
|
||||
|
||||
oidc_settings.cors_origins = ["http://localhost:3000"]
|
||||
oidc_settings.oidc_redirect_uri = "https://homelable.example.com/api/v1/auth/oidc/callback"
|
||||
|
||||
assert origin_is_allowed("https://homelable.example.com") is True
|
||||
assert origin_is_allowed("https://homelable.example.com/") is True
|
||||
assert origin_is_allowed("http://localhost:3000") is True
|
||||
assert origin_is_allowed("https://evil.example") is False
|
||||
assert origin_is_allowed(None) is False
|
||||
|
||||
# A redirect URI that is not an absolute URL contributes no origin.
|
||||
oidc_settings.oidc_redirect_uri = "/api/v1/auth/oidc/callback"
|
||||
assert origin_is_allowed("https://homelable.example.com") is False
|
||||
|
||||
|
||||
def test_oidc_settings_warn_when_cors_origins_omits_the_app_origin(caplog):
|
||||
from app.core.config import Settings, origin_of
|
||||
|
||||
base = {
|
||||
"secret_key": "x" * 32,
|
||||
"auth_mode": "oidc",
|
||||
"oidc_discovery_url": "https://idp.example/.well-known/openid-configuration",
|
||||
"oidc_client_id": "homelable",
|
||||
"oidc_client_secret": "secret",
|
||||
"oidc_redirect_uri": "https://homelable.example.com/api/v1/auth/oidc/callback",
|
||||
}
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["http://localhost:3000"])
|
||||
# Asserted against the origin the settings derive rather than one spelled
|
||||
# out again here: this checks the warning names that exact origin, and a
|
||||
# URL literal on the left of an `in` reads to a scanner as a host check
|
||||
# that a substring match would not make safely.
|
||||
assert origin_of(base["oidc_redirect_uri"]) in caplog.text
|
||||
assert "CORS_ORIGINS" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["https://homelable.example.com/"])
|
||||
assert caplog.text == ""
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -119,6 +119,18 @@ async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, hea
|
||||
assert node["right_handles"] == 4
|
||||
|
||||
|
||||
async def test_save_canvas_clamps_per_side_handles(client: AsyncClient, headers: dict):
|
||||
# Same 0..64 bound the create/update routes apply (#435): the renderer clamps
|
||||
# at draw time, so a saved count above it would draw a node the row doesn't
|
||||
# describe.
|
||||
n1 = node_payload(label="Sw", type="switch", bottom_handles=5000, left_handles=-2)
|
||||
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["bottom_handles"] == 64
|
||||
assert node["left_handles"] == 0
|
||||
|
||||
|
||||
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")
|
||||
@@ -706,3 +718,90 @@ async def test_save_canvas_custom_style_overwrite(client: AsyncClient, 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"]
|
||||
|
||||
|
||||
# ── self-parent guard (#370) ─────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_drops_a_node_parented_to_itself(client: AsyncClient, headers: dict):
|
||||
# A self-parent row freezes the node on the canvas and survives a reload, so
|
||||
# the save normalizes it away. Dropped rather than rejected: a canvas that
|
||||
# already carries the bad row must still be able to save.
|
||||
n = node_payload(type="lxc", label="pihole")
|
||||
n["parent_id"] = n["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save", json={"nodes": [n], "edges": [], "viewport": {}}, headers=headers
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
saved = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]
|
||||
assert len(saved) == 1
|
||||
assert saved[0]["parent_id"] is None
|
||||
|
||||
|
||||
async def test_save_canvas_keeps_a_real_parent(client: AsyncClient, headers: dict):
|
||||
host = node_payload(type="proxmox", label="pve", container_mode=True)
|
||||
child = node_payload(type="lxc", label="pihole", parent_id=host["id"])
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save",
|
||||
json={"nodes": [host, child], "edges": [], "viewport": {}},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
saved = {n["label"]: n for n in (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]}
|
||||
assert saved["pihole"]["parent_id"] == host["id"]
|
||||
|
||||
|
||||
# ── furniture descriptions ───────────────────────────────────────────────────
|
||||
|
||||
async def test_save_canvas_keeps_zone_and_group_descriptions(client: AsyncClient, headers: dict):
|
||||
# Regression: a zone's and a group's description used to ride on `notes`,
|
||||
# which the save routes to the inventory row. Furniture has no such row, so
|
||||
# the text was dropped without an error. It lives on `nodes.description` now.
|
||||
zone = node_payload(type="groupRect", label="Garage", description="Everything behind the garage door.")
|
||||
group = node_payload(type="group", label="Cluster", description="The three Proxmox boxes.")
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/canvas/save", json={"nodes": [zone, group], "edges": [], "viewport": {}}, headers=headers
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
saved = {n["label"]: n for n in (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]}
|
||||
assert saved["Garage"]["description"] == "Everything behind the garage door."
|
||||
assert saved["Cluster"]["description"] == "The three Proxmox boxes."
|
||||
|
||||
|
||||
async def test_save_canvas_edits_a_furniture_description(client: AsyncClient, headers: dict):
|
||||
zone = node_payload(type="groupRect", label="Garage", description="First take.")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [zone], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
zone["description"] = "Second take."
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [zone], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
saved = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]
|
||||
assert saved[0]["description"] == "Second take."
|
||||
|
||||
|
||||
async def test_save_canvas_clears_a_furniture_description(client: AsyncClient, headers: dict):
|
||||
zone = node_payload(type="groupRect", label="Garage", description="Written once.")
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [zone], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
zone["description"] = ""
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [zone], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
saved = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"]
|
||||
assert saved[0]["description"] == ""
|
||||
|
||||
|
||||
async def test_save_canvas_drops_a_description_on_a_device_node(client: AsyncClient, headers: dict):
|
||||
# A node that draws a device keeps its text on the inventory row, as `notes`.
|
||||
# `description` is furniture-only, so it is cleared rather than shadowing it.
|
||||
n = node_payload(type="nas", label="nas-01", ip="192.168.1.20", notes="Backs up nightly.", description="Not here.")
|
||||
|
||||
await client.post("/api/v1/canvas/save", json={"nodes": [n], "edges": [], "viewport": {}}, headers=headers)
|
||||
|
||||
saved = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
||||
assert saved["description"] is None
|
||||
assert saved["notes"] == "Backs up nightly."
|
||||
|
||||
@@ -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,405 @@
|
||||
"""Tests for the inventory duplicate repair (app.services.device_merge).
|
||||
|
||||
Two shapes, one merge: the user picks the survivor by hand, or an import proves
|
||||
two rows are one device by their shared MAC. What both must guarantee is that
|
||||
nothing is lost — not a fact, not a canvas node, not a rack mount, not a
|
||||
document, and not the *visibility* of the facts that just arrived on a canvas
|
||||
that never saw them.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import (
|
||||
Design,
|
||||
Document,
|
||||
InventoryDevice,
|
||||
InventoryDeviceLink,
|
||||
Node,
|
||||
Rack,
|
||||
RackDevice,
|
||||
)
|
||||
from app.services.device_merge import merge_devices, reconcile_duplicates
|
||||
|
||||
|
||||
def _at(day: int) -> datetime:
|
||||
return datetime(2026, 1, day, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
async def _design(db, name="d1"):
|
||||
design = Design(name=name)
|
||||
db.add(design)
|
||||
await db.flush()
|
||||
return design
|
||||
|
||||
|
||||
async def _device(db, **kwargs):
|
||||
kwargs.setdefault("discovered_at", _at(1))
|
||||
device = InventoryDevice(**kwargs)
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
return device
|
||||
|
||||
|
||||
async def _node(db, design, device, **kwargs):
|
||||
node = Node(design_id=design.id, device_id=device.id, label=kwargs.pop("label", "n8n"),
|
||||
type=kwargs.pop("type", "lxc"), **kwargs)
|
||||
db.add(node)
|
||||
await db.flush()
|
||||
return node
|
||||
|
||||
|
||||
async def _rack(db, design):
|
||||
rack = Rack(design_id=design.id, name="R1", u_height=42)
|
||||
db.add(rack)
|
||||
await db.flush()
|
||||
return rack
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_unions_facts_without_overwriting_the_survivor(db_session):
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
hostname="n8n", label="n8n", vendor="Proxmox VE", status="pending",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
properties=[{"key": "VMID", "value": "130", "visible": True}],
|
||||
discovery_source="arp", discovery_sources=["arp", "proxmox"],
|
||||
discovered_at=_at(5),
|
||||
)
|
||||
loser = await _device(
|
||||
db_session, id="l", ip="10.0.0.9", mac=None, hostname="ignored",
|
||||
notes="runs the automations", cpu_count=4, status="approved",
|
||||
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
|
||||
properties=[{"key": "Owner", "value": "me", "visible": True}],
|
||||
discovery_source="canvas", discovery_sources=["canvas"],
|
||||
discovered_at=_at(2),
|
||||
)
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
assert result["merged"] == 1
|
||||
assert await db_session.get(InventoryDevice, "l") is None
|
||||
# Established facts are never overwritten by the folded-in row...
|
||||
assert winner.hostname == "n8n"
|
||||
# ...but every gap it can fill, it fills.
|
||||
assert winner.notes == "runs the automations"
|
||||
assert winner.cpu_count == 4
|
||||
# Addresses and lists union; sources accumulate.
|
||||
assert winner.ip == "192.168.1.62, 10.0.0.9"
|
||||
assert {s["service_name"] for s in winner.services} == {"ssh", "http"}
|
||||
assert {p["key"] for p in winner.properties} == {"VMID", "Owner"}
|
||||
assert set(winner.discovery_sources) == {"arp", "proxmox", "canvas"}
|
||||
# Drawn on a canvas, so the survivor cannot go back to the pending queue.
|
||||
assert winner.status == "approved"
|
||||
# Known since the earliest of the two rows saw it.
|
||||
assert winner.discovered_at == _at(2)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_repoints_canvas_nodes_and_rack_mounts(db_session):
|
||||
design = await _design(db_session)
|
||||
other = await _design(db_session, name="d2")
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", label="n8n", status="approved")
|
||||
|
||||
kept = await _node(db_session, design, winner, pos_x=10, pos_y=20)
|
||||
moved = await _node(db_session, other, loser, pos_x=300, pos_y=400)
|
||||
rack = await _rack(db_session, design)
|
||||
mount = RackDevice(design_id=design.id, rack_id=rack.id, device_id=loser.id, label="n8n")
|
||||
db_session.add(mount)
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
# The node on the other canvas keeps its position and now draws the survivor.
|
||||
await db_session.refresh(moved)
|
||||
assert moved.device_id == "w"
|
||||
assert (moved.pos_x, moved.pos_y) == (300, 400)
|
||||
assert (await db_session.get(Node, kept.id)).device_id == "w"
|
||||
# The mount follows too — a rack that rendered before still renders.
|
||||
await db_session.refresh(mount)
|
||||
assert mount.device_id == "w"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_shows_the_new_facts_on_a_canvas_that_never_saw_them(db_session):
|
||||
"""The point of the merge: a poorer node must gain the richer row's facts.
|
||||
|
||||
`display_view` is a whitelist — without extending it the folded-in services
|
||||
and properties would render hidden and the canvas would look unchanged.
|
||||
"""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
properties=[{"key": "VMID", "value": "130", "visible": True}],
|
||||
)
|
||||
loser = await _device(
|
||||
db_session, id="l", ip="192.168.1.62",
|
||||
services=[{"service_name": "http", "port": 5678, "protocol": "tcp"}],
|
||||
properties=[],
|
||||
)
|
||||
poor = await _node(db_session, design, loser)
|
||||
poor.display_view = {
|
||||
"services": [{"key": "5678|tcp|http", "visible": True}],
|
||||
"properties": [],
|
||||
}
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(poor)
|
||||
|
||||
services = poor.display_view["services"]
|
||||
assert [e["visible"] for e in services] == [True, True]
|
||||
assert {e["key"] for e in services} == {"5678|tcp|http", "22|tcp|ssh"}
|
||||
# A property list this canvas had never seen picks up the survivor's.
|
||||
assert [e["key"] for e in poor.display_view["properties"]] == ["vmid"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_keeps_a_deliberately_hidden_fact_hidden(db_session):
|
||||
design = await _design(db_session)
|
||||
winner = await _device(
|
||||
db_session, id="w", ip="192.168.1.62",
|
||||
services=[{"service_name": "ssh", "port": 22, "protocol": "tcp"}],
|
||||
)
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", services=[])
|
||||
node = await _node(db_session, design, loser)
|
||||
node.display_view = {"services": [{"key": "22|tcp|ssh", "visible": False}], "properties": []}
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(node)
|
||||
|
||||
assert node.display_view["services"] == [{"key": "22|tcp|ssh", "visible": False}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_collapses_two_nodes_that_now_draw_one_device(db_session):
|
||||
"""Both rows drawn on the *same* canvas: one node survives, the oldest."""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
|
||||
keep = await _node(db_session, design, winner, pos_x=10, pos_y=20)
|
||||
await _node(db_session, design, loser, pos_x=99, pos_y=99)
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
nodes = (
|
||||
await db_session.execute(select(Node).where(Node.design_id == design.id))
|
||||
).scalars().all()
|
||||
assert [n.id for n in nodes] == [keep.id]
|
||||
assert (nodes[0].pos_x, nodes[0].pos_y) == (10, 20)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_rack_mount_survives_the_node_collapse(db_session):
|
||||
"""`rack_devices.node_id` must not be left naming a deleted duplicate node."""
|
||||
design = await _design(db_session)
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", status="approved")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", status="approved")
|
||||
keep = await _node(db_session, design, winner)
|
||||
dup = await _node(db_session, design, loser)
|
||||
rack = await _rack(db_session, design)
|
||||
mount = RackDevice(
|
||||
design_id=design.id, rack_id=rack.id, device_id=loser.id, node_id=dup.id, label="n8n"
|
||||
)
|
||||
db_session.add(mount)
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(mount)
|
||||
|
||||
assert mount.device_id == "w"
|
||||
assert mount.node_id == keep.id
|
||||
assert await db_session.get(Node, dup.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_adopts_a_document_or_orphans_it(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62")
|
||||
doc = Document(title="n8n", slug="n8n", device_id=loser.id, body="# n8n")
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(doc)
|
||||
|
||||
# No document on the survivor, so the loser's is adopted rather than orphaned.
|
||||
assert doc.device_id == "w"
|
||||
assert result["documents_orphaned"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_document_is_orphaned_not_deleted(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62")
|
||||
mine = Document(title="n8n", slug="n8n", device_id=winner.id, body="kept")
|
||||
theirs = Document(title="n8n (dup)", slug="n8n-dup", device_id=loser.id, body="written by hand")
|
||||
db_session.add_all([mine, theirs])
|
||||
await db_session.flush()
|
||||
|
||||
result = await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
await db_session.refresh(theirs)
|
||||
|
||||
assert result["documents_orphaned"] == 1
|
||||
assert theirs.device_id is None
|
||||
assert theirs.body == "written by hand" # survives as an orphan
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_moves_mesh_links_onto_the_survivor_ieee(db_session):
|
||||
winner = await _device(db_session, id="w", ieee_address="pve-pve1-130", ip="192.168.1.62")
|
||||
loser = await _device(db_session, id="l", ieee_address="0xAABB", ip="192.168.1.62")
|
||||
db_session.add(InventoryDeviceLink(source_ieee="pve-host", target_ieee="0xAABB"))
|
||||
db_session.add(InventoryDeviceLink(source_ieee="0xAABB", target_ieee="0xCCDD"))
|
||||
await db_session.flush()
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
links = (await db_session.execute(select(InventoryDeviceLink))).scalars().all()
|
||||
pairs = {(link.source_ieee, link.target_ieee) for link in links}
|
||||
assert pairs == {("pve-host", "pve-pve1-130"), ("pve-pve1-130", "0xCCDD")}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_survivor_adopts_an_ieee_it_lacks(db_session):
|
||||
winner = await _device(db_session, id="w", ip="192.168.1.62", ieee_address=None)
|
||||
loser = await _device(db_session, id="l", ip="192.168.1.62", ieee_address="pve-pve1-130")
|
||||
|
||||
await merge_devices(db_session, winner, [loser])
|
||||
await db_session.flush()
|
||||
|
||||
assert winner.ieee_address == "pve-pve1-130"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_merges_rows_sharing_a_mac(db_session):
|
||||
"""The reported case: a Proxmox row and a canvas row, same NIC."""
|
||||
scanned = await _device(
|
||||
db_session, id="scanned", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
ieee_address="pve-proxmox-ai-130", status="pending", discovered_at=_at(1),
|
||||
)
|
||||
from_canvas = await _device(
|
||||
db_session, id="canvas", ip="192.168.1.62", mac="bc:24:11:95:af:8c",
|
||||
label="n8n", status="approved", discovered_at=_at(20),
|
||||
)
|
||||
|
||||
merged = await reconcile_duplicates(db_session)
|
||||
await db_session.flush()
|
||||
|
||||
assert merged == 1
|
||||
# The IEEE-bearing row wins: the Proxmox import and the link graph key on it.
|
||||
assert await db_session.get(InventoryDevice, from_canvas.id) is None
|
||||
assert (await db_session.get(InventoryDevice, scanned.id)).label == "n8n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_leaves_rows_that_only_share_an_ip(db_session):
|
||||
"""An IP is not an identity — a re-used lease must not merge two machines."""
|
||||
await _device(db_session, id="a", ip="192.168.1.62", mac="aa:aa:aa:aa:aa:aa")
|
||||
await _device(db_session, id="b", ip="192.168.1.62", mac="bb:bb:bb:bb:bb:bb")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_leaves_two_proxmox_guests_sharing_a_mac(db_session):
|
||||
"""Distinct synthetic IEEEs are decisive: two guests are never one device."""
|
||||
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-130")
|
||||
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ieee_address="pve-pve1-131")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
assert len((await db_session.execute(select(InventoryDevice))).scalars().all()) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_never_touches_a_hidden_row(db_session):
|
||||
"""A hidden row was hidden on purpose — merging it would resurrect it."""
|
||||
await _device(db_session, id="visible", mac="bc:24:11:95:af:8c", status="pending")
|
||||
await _device(db_session, id="hidden", mac="bc:24:11:95:af:8c", status="hidden")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
hidden = await db_session.get(InventoryDevice, "hidden")
|
||||
assert hidden is not None and hidden.status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconcile_is_idempotent(db_session):
|
||||
await _device(db_session, id="a", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
|
||||
await _device(db_session, id="b", mac="bc:24:11:95:af:8c", ip="192.168.1.62")
|
||||
|
||||
assert await reconcile_duplicates(db_session) == 1
|
||||
await db_session.flush()
|
||||
assert await reconcile_duplicates(db_session) == 0
|
||||
|
||||
|
||||
# --- The route -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_returns_the_survivor(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62", label="n8n", status="approved")
|
||||
await _device(db_session, id="l", hostname="n8n", notes="the good notes")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["l"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["id"] == "w"
|
||||
assert body["notes"] == "the good notes"
|
||||
assert await db_session.get(InventoryDevice, "l") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_rejects_a_single_device(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["w"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_404s_on_an_unknown_row(client, headers, db_session):
|
||||
await _device(db_session, id="w", ip="192.168.1.62")
|
||||
await db_session.commit()
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge",
|
||||
json={"winner_id": "w", "loser_ids": ["nope"]},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_merge_route_requires_auth(client, db_session):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/merge", json={"winner_id": "w", "loser_ids": ["l"]}
|
||||
)
|
||||
assert res.status_code in (401, 403)
|
||||
@@ -0,0 +1,254 @@
|
||||
"""Backlinks — the inverse of a wiki-link.
|
||||
|
||||
Two halves. The pure resolution rules, which must stay identical to
|
||||
`frontend/src/documentation/wikilinks.ts`, and the route, which has to find a
|
||||
target document that carries no links of its own and must not pay for the
|
||||
inventory when nothing links to a device.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.services import doc_backlinks
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDoc:
|
||||
id: str
|
||||
slug: str = ""
|
||||
title: str = ""
|
||||
body: str = ""
|
||||
device_id: str | None = None
|
||||
node_id: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDevice:
|
||||
id: str
|
||||
label: str | None = None
|
||||
friendly_name: str | None = None
|
||||
hostname: str | None = None
|
||||
ip: str | None = None
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_bare_link_is_a_document_link():
|
||||
link = doc_backlinks.parse_link("VLAN plan")
|
||||
assert link is not None
|
||||
assert (link.target, link.key, link.label) == ("doc", "VLAN plan", "VLAN plan")
|
||||
|
||||
|
||||
def test_a_prefix_picks_the_target():
|
||||
assert doc_backlinks.parse_link("device:nas-01").target == "device"
|
||||
assert doc_backlinks.parse_link("NODE:abc").target == "node"
|
||||
assert doc_backlinks.parse_link("doc:vlan-plan").target == "doc"
|
||||
|
||||
|
||||
def test_an_unknown_prefix_stays_part_of_the_key():
|
||||
link = doc_backlinks.parse_link("http://nas.lan")
|
||||
assert link is not None
|
||||
assert (link.target, link.key) == ("doc", "http://nas.lan")
|
||||
|
||||
|
||||
def test_a_pipe_sets_the_label():
|
||||
link = doc_backlinks.parse_link("device:nas-01|the big NAS")
|
||||
assert link is not None
|
||||
assert (link.key, link.label) == ("nas-01", "the big NAS")
|
||||
|
||||
|
||||
def test_an_empty_link_is_not_a_link():
|
||||
assert doc_backlinks.parse_link("") is None
|
||||
assert doc_backlinks.parse_link("device:") is None
|
||||
|
||||
|
||||
def test_links_are_collected_with_their_line():
|
||||
body = "intro\nsee [[a]] and [[b]]\n"
|
||||
found = list(doc_backlinks.iter_links(body))
|
||||
assert [link.key for _, link in found] == ["a", "b"]
|
||||
assert {line for line, _ in found} == {"see [[a]] and [[b]]"}
|
||||
|
||||
|
||||
# ── resolution ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve(inner: str, docs: list[FakeDoc], devices: list[FakeDevice] | None = None):
|
||||
link = doc_backlinks.parse_link(inner)
|
||||
assert link is not None
|
||||
return doc_backlinks.resolve(link, docs, devices or [])
|
||||
|
||||
|
||||
def test_a_document_resolves_by_id_then_slug_then_title():
|
||||
docs = [
|
||||
FakeDoc(id="d1", slug="vlan-plan", title="VLAN plan"),
|
||||
FakeDoc(id="d2", slug="other", title="Other"),
|
||||
]
|
||||
assert _resolve("d1", docs) == "d1"
|
||||
assert _resolve("vlan-plan", docs) == "d1"
|
||||
assert _resolve("VLAN PLAN", docs) == "d1"
|
||||
|
||||
|
||||
def test_an_unmatched_document_link_resolves_to_nothing():
|
||||
assert _resolve("nowhere", [FakeDoc(id="d1", slug="s", title="t")]) is None
|
||||
|
||||
|
||||
def test_a_device_link_resolves_by_id_or_label():
|
||||
docs = [FakeDoc(id="d1", device_id="dev1")]
|
||||
devices = [FakeDevice(id="dev1", label="nas-01")]
|
||||
assert _resolve("device:dev1", docs, devices) == "d1"
|
||||
assert _resolve("device:NAS-01", docs, devices) == "d1"
|
||||
|
||||
|
||||
def test_a_device_label_falls_back_the_way_the_tree_does():
|
||||
assert doc_backlinks.device_label(FakeDevice(id="x", hostname="h")) == "h"
|
||||
assert doc_backlinks.device_label(FakeDevice(id="x", ip="10.0.0.1")) == "10.0.0.1"
|
||||
assert doc_backlinks.device_label(FakeDevice(id="x")) == "Unnamed device"
|
||||
|
||||
|
||||
def test_a_device_with_no_document_resolves_to_nothing():
|
||||
assert _resolve("device:dev1", [], [FakeDevice(id="dev1", label="nas")]) is None
|
||||
|
||||
|
||||
def test_a_node_link_resolves_through_node_id():
|
||||
docs = [FakeDoc(id="d1", node_id="n1")]
|
||||
assert _resolve("node:n1", docs) == "d1"
|
||||
assert _resolve("node:n2", docs) is None
|
||||
|
||||
|
||||
# ── inversion ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_document_that_links_here_is_a_backlink():
|
||||
docs = [
|
||||
FakeDoc(id="d1", slug="a", title="A", body="see [[B]]"),
|
||||
FakeDoc(id="d2", slug="b", title="B", body="no links"),
|
||||
]
|
||||
hits = doc_backlinks.backlinks_for("d2", docs, [])
|
||||
assert [h.doc_id for h in hits] == ["d1"]
|
||||
assert hits[0].context == "see [[B]]"
|
||||
|
||||
|
||||
def test_a_document_does_not_link_to_itself():
|
||||
docs = [FakeDoc(id="d1", slug="a", title="A", body="[[A]] again")]
|
||||
assert doc_backlinks.backlinks_for("d1", docs, []) == []
|
||||
|
||||
|
||||
def test_two_links_from_one_document_are_one_backlink_with_a_count():
|
||||
docs = [
|
||||
FakeDoc(id="d1", slug="a", title="A", body="[[B]] and later [[b]]"),
|
||||
FakeDoc(id="d2", slug="b", title="B"),
|
||||
]
|
||||
hits = doc_backlinks.backlinks_for("d2", docs, [])
|
||||
assert len(hits) == 1
|
||||
assert hits[0].count == 2
|
||||
|
||||
|
||||
def test_the_written_label_is_kept():
|
||||
docs = [
|
||||
FakeDoc(id="d1", slug="a", title="A", body="[[B|the other one]]"),
|
||||
FakeDoc(id="d2", slug="b", title="B"),
|
||||
]
|
||||
assert doc_backlinks.backlinks_for("d2", docs, [])[0].label == "the other one"
|
||||
|
||||
|
||||
def test_a_long_line_is_windowed_around_the_link():
|
||||
filler = "word " * 60
|
||||
docs = [
|
||||
FakeDoc(id="d1", slug="a", title="A", body=f"{filler}[[B]]{filler}"),
|
||||
FakeDoc(id="d2", slug="b", title="B"),
|
||||
]
|
||||
context = doc_backlinks.backlinks_for("d2", docs, [])[0].context
|
||||
assert "[[B]]" in context
|
||||
assert len(context) < 200
|
||||
|
||||
|
||||
def test_has_device_link_only_fires_on_a_device_link():
|
||||
assert doc_backlinks.has_device_link([FakeDoc(id="d", body="[[device:x]]")])
|
||||
assert not doc_backlinks.has_device_link([FakeDoc(id="d", body="[[plain]]")])
|
||||
|
||||
|
||||
# ── route ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_backlinks_requires_auth(client: AsyncClient):
|
||||
assert (await client.get("/api/v1/documents/x/backlinks")).status_code == 401
|
||||
|
||||
|
||||
async def test_backlinks_404_on_an_unknown_document(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/documents/nope/backlinks", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_the_route_finds_a_target_that_carries_no_links(client: AsyncClient, headers: dict):
|
||||
"""The target's own body has no `[[`, so it is only ever a resolution target."""
|
||||
target = (
|
||||
await client.post("/api/v1/documents", json={"title": "VLAN plan"}, headers=headers)
|
||||
).json()
|
||||
source = (
|
||||
await client.post("/api/v1/documents", json={"title": "Runbook"}, headers=headers)
|
||||
).json()
|
||||
await client.patch(
|
||||
f"/api/v1/documents/{source['id']}",
|
||||
json={"body": "Read the [[VLAN plan]] first."},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)
|
||||
assert res.status_code == 200
|
||||
hits = res.json()
|
||||
assert [h["doc_id"] for h in hits] == [source["id"]]
|
||||
assert hits[0]["title"] == "Runbook"
|
||||
assert hits[0]["label"] == "VLAN plan"
|
||||
assert hits[0]["context"] == "Read the [[VLAN plan]] first."
|
||||
|
||||
|
||||
async def test_a_device_document_is_reachable_by_its_device_link(client: AsyncClient, headers: dict):
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending",
|
||||
json={
|
||||
"label": "nas-01",
|
||||
"hostname": "nas-01.lan",
|
||||
"ip": "192.168.1.20",
|
||||
"discovery_source": "manual",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code in (200, 201), res.text
|
||||
device = res.json()
|
||||
doc = (
|
||||
await client.post(
|
||||
"/api/v1/documents",
|
||||
json={"title": "nas-01", "kind": "device", "device_id": device["id"]},
|
||||
headers=headers,
|
||||
)
|
||||
).json()
|
||||
source = (
|
||||
await client.post("/api/v1/documents", json={"title": "Backups"}, headers=headers)
|
||||
).json()
|
||||
await client.patch(
|
||||
f"/api/v1/documents/{source['id']}",
|
||||
json={"body": "Runs on [[device:nas-01]]."},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
hits = (await client.get(f"/api/v1/documents/{doc['id']}/backlinks", headers=headers)).json()
|
||||
assert [h["doc_id"] for h in hits] == [source["id"]]
|
||||
assert hits[0]["device_id"] is None # the *source* is a page, not a device
|
||||
|
||||
|
||||
async def test_an_unresolved_link_is_not_a_backlink(client: AsyncClient, headers: dict):
|
||||
target = (
|
||||
await client.post("/api/v1/documents", json={"title": "Target"}, headers=headers)
|
||||
).json()
|
||||
source = (
|
||||
await client.post("/api/v1/documents", json={"title": "Source"}, headers=headers)
|
||||
).json()
|
||||
await client.patch(
|
||||
f"/api/v1/documents/{source['id']}",
|
||||
json={"body": "Points at [[Something else]]."},
|
||||
headers=headers,
|
||||
)
|
||||
hits = (await client.get(f"/api/v1/documents/{target['id']}/backlinks", headers=headers)).json()
|
||||
assert hits == []
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Exporting the documentation space as a zip of `.md` files.
|
||||
|
||||
The promise is that unzipping gives back what the sidebar shows, byte for byte:
|
||||
the Library tree as directories, everything filed by a link in a directory named
|
||||
after its kind, and every body exactly as it was written.
|
||||
"""
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.services.doc_export import ExportDoc, build_zip, export_paths, safe_segment
|
||||
|
||||
|
||||
def _doc(id: str, kind: str = "page", *, title: str = "", slug: str = "", parent_id=None, body: str = "") -> ExportDoc:
|
||||
return ExportDoc(
|
||||
id=id,
|
||||
kind=kind,
|
||||
title=title or id,
|
||||
slug=slug or id,
|
||||
parent_id=parent_id,
|
||||
body=body,
|
||||
)
|
||||
|
||||
|
||||
async def _device(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
payload = {"label": "nas-01", "hostname": "nas-01.lan", "ip": "192.168.1.20", "discovery_source": "manual", **body}
|
||||
res = await client.post("/api/v1/scan/pending", json=payload, headers=headers)
|
||||
assert res.status_code in (200, 201), res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
res = await client.post("/api/v1/documents", json={"title": "Page", **body}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
def _entries(payload: bytes) -> dict[str, str]:
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
|
||||
return {name: archive.read(name).decode() for name in archive.namelist()}
|
||||
|
||||
|
||||
# ── path layout ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_a_page_at_the_root_is_a_file_at_the_root():
|
||||
assert export_paths([_doc("vlan-plan")]) == [("vlan-plan.md", "")]
|
||||
|
||||
|
||||
def test_a_page_in_a_folder_lands_under_its_directory():
|
||||
folder = _doc("network", kind="folder")
|
||||
page = _doc("vlans", parent_id="network")
|
||||
paths = dict(export_paths([folder, page]))
|
||||
assert paths["network/vlans.md"] == ""
|
||||
|
||||
|
||||
def test_a_folder_body_becomes_the_index_of_its_own_directory():
|
||||
folder = _doc("network", kind="folder", body="# Network\n")
|
||||
assert dict(export_paths([folder]))["network/index.md"] == "# Network\n"
|
||||
|
||||
|
||||
def test_nesting_goes_as_deep_as_the_tree():
|
||||
paths = dict(
|
||||
export_paths(
|
||||
[
|
||||
_doc("a", kind="folder"),
|
||||
_doc("b", kind="folder", parent_id="a"),
|
||||
_doc("leaf", parent_id="b"),
|
||||
]
|
||||
)
|
||||
)
|
||||
assert "a/b/leaf.md" in paths
|
||||
assert "a/b/index.md" in paths
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,directory",
|
||||
[("device", "devices"), ("node", "nodes"), ("design", "designs")],
|
||||
)
|
||||
def test_linked_kinds_export_into_a_directory_named_for_the_kind(kind, directory):
|
||||
assert export_paths([_doc("nas-01", kind=kind)]) == [(f"{directory}/nas-01.md", "")]
|
||||
|
||||
|
||||
def test_a_linked_document_ignores_a_stray_parent():
|
||||
"""Only page/folder are placed by `parent_id`; a device is placed by its link."""
|
||||
paths = dict(export_paths([_doc("network", kind="folder"), _doc("nas", kind="device", parent_id="network")]))
|
||||
assert "devices/nas.md" in paths
|
||||
|
||||
|
||||
def test_the_body_is_written_through_untouched():
|
||||
body = "---\ntags: [edge]\n---\n\n# OPNsense\n\nWAN on igb0.\n"
|
||||
assert export_paths([_doc("opnsense", body=body)])[0][1] == body
|
||||
|
||||
|
||||
def test_a_missing_parent_files_the_page_at_the_root():
|
||||
"""A folder deleted out from under a page must not lose the page."""
|
||||
assert export_paths([_doc("orphan", parent_id="gone")]) == [("orphan.md", "")]
|
||||
|
||||
|
||||
def test_a_parent_cycle_terminates_instead_of_spinning():
|
||||
docs = [
|
||||
_doc("a", kind="folder", parent_id="b"),
|
||||
_doc("b", kind="folder", parent_id="a"),
|
||||
]
|
||||
paths = [path for path, _ in export_paths(docs)]
|
||||
assert len(paths) == 2
|
||||
assert all(path.endswith("index.md") for path in paths)
|
||||
|
||||
|
||||
# ── collisions and unsafe names ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_two_documents_wanting_one_path_both_survive():
|
||||
"""Slugs are unique among siblings only, so a collision is reachable."""
|
||||
paths = [path for path, _ in export_paths([_doc("a", title="Same", slug="same"), _doc("b", title="Same", slug="same")])]
|
||||
assert sorted(paths) == ["same-2.md", "same.md"]
|
||||
|
||||
|
||||
def test_a_collision_only_differing_by_case_is_still_a_collision():
|
||||
"""Unzipping on macOS or Windows would otherwise overwrite one of them."""
|
||||
paths = [path for path, _ in export_paths([_doc("a", slug="Same"), _doc("b", slug="same")])]
|
||||
assert len(set(path.lower() for path in paths)) == 2
|
||||
|
||||
|
||||
def test_a_collision_keeps_both_bodies():
|
||||
entries = dict(export_paths([_doc("a", slug="same", body="first"), _doc("b", slug="same", body="second")]))
|
||||
assert sorted(entries.values()) == ["first", "second"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("../../etc/passwd", "etc-passwd"),
|
||||
("..", "untitled"),
|
||||
("/", "untitled"),
|
||||
("", "untitled"),
|
||||
(" ", "untitled"),
|
||||
(".hidden", "hidden"),
|
||||
("Salle des machines", "salle-des-machines"),
|
||||
("a/b", "a-b"),
|
||||
],
|
||||
)
|
||||
def test_a_segment_cannot_escape_the_archive(raw, expected):
|
||||
assert safe_segment(raw) == expected
|
||||
|
||||
|
||||
def test_an_unsafe_title_cannot_escape_the_archive():
|
||||
path = export_paths([_doc("x", slug="../../../etc/passwd")])[0][0]
|
||||
assert ".." not in path
|
||||
assert not path.startswith("/")
|
||||
|
||||
|
||||
# ── the archive ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_the_archive_reads_back_as_a_zip():
|
||||
payload = build_zip([_doc("vlan-plan", body="# VLANs\n")])
|
||||
assert _entries(payload) == {"vlan-plan.md": "# VLANs\n"}
|
||||
|
||||
|
||||
def test_an_empty_documentation_space_is_an_empty_archive():
|
||||
assert _entries(build_zip([])) == {}
|
||||
|
||||
|
||||
# ── the route ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_export_requires_auth(client: AsyncClient):
|
||||
assert (await client.get("/api/v1/documents/export")).status_code == 401
|
||||
|
||||
|
||||
async def test_export_is_not_read_as_a_document_id(client: AsyncClient, headers: dict):
|
||||
"""`/export` is declared above `/{document_id}` — a 404 would mean it is not."""
|
||||
res = await client.get("/api/v1/documents/export", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.headers["content-type"] == "application/zip"
|
||||
|
||||
|
||||
async def test_export_names_the_download(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/documents/export", headers=headers)
|
||||
assert res.headers["content-disposition"].startswith('attachment; filename="homelable-documentation-')
|
||||
assert res.headers["content-disposition"].endswith('.zip"')
|
||||
assert "Content-Disposition" in res.headers["access-control-expose-headers"]
|
||||
|
||||
|
||||
async def test_export_mirrors_the_library_tree(client: AsyncClient, headers: dict):
|
||||
folder = await _create(client, headers, title="Network", kind="folder")
|
||||
await _create(client, headers, title="VLAN plan", parent_id=folder["id"])
|
||||
|
||||
res = await client.get("/api/v1/documents/export", headers=headers)
|
||||
entries = _entries(res.content)
|
||||
assert "network/vlan-plan.md" in entries
|
||||
assert "network/index.md" in entries
|
||||
|
||||
|
||||
async def test_export_carries_the_body_the_user_saved(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Runbook")
|
||||
body = "---\ntags: [ops]\n---\n\n# Runbook\n\nPull the plug.\n"
|
||||
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": body}, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
|
||||
assert entries["runbook.md"] == body
|
||||
|
||||
|
||||
async def test_export_files_a_device_document_under_devices(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
|
||||
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
|
||||
assert "devices/nas-01.md" in entries
|
||||
assert entries["devices/nas-01.md"].strip()
|
||||
|
||||
|
||||
async def test_export_includes_every_document(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
folder = await _create(client, headers, title="Ops", kind="folder")
|
||||
await _create(client, headers, title="Incident 42", parent_id=folder["id"])
|
||||
await _create(client, headers, title="Loose page")
|
||||
|
||||
listed = (await client.get("/api/v1/documents", headers=headers)).json()
|
||||
entries = _entries((await client.get("/api/v1/documents/export", headers=headers)).content)
|
||||
assert len(entries) == len(listed)
|
||||
|
||||
|
||||
async def test_export_of_an_empty_space_is_a_valid_empty_zip(client: AsyncClient, headers: dict):
|
||||
res = await client.get("/api/v1/documents/export", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert _entries(res.content) == {}
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Search over documents, on both engines.
|
||||
|
||||
FTS5 is not guaranteed by the SQLite build the LXC and Docker images ship, so
|
||||
the LIKE fallback is a supported path, not a safety net — it gets the same
|
||||
coverage as the index.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document
|
||||
from app.services import doc_search
|
||||
|
||||
|
||||
def _doc(**kwargs) -> Document:
|
||||
base = dict(kind="page", title="T", slug="t", body="", tags=[])
|
||||
base.update(kwargs)
|
||||
return Document(**base)
|
||||
|
||||
|
||||
async def _seed(db: AsyncSession) -> list[Document]:
|
||||
docs = [
|
||||
_doc(title="NAS", slug="nas", body="The Synology holds every backup.", tags=["storage"]),
|
||||
_doc(title="Router", slug="router", body="Runs OPNsense on 192.168.1.1.", tags=["network"]),
|
||||
_doc(title="VLAN plan", slug="vlan-plan", body="Guest traffic is isolated.", tags=["network"]),
|
||||
]
|
||||
for doc in docs:
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
await doc_search.index_document(db, doc)
|
||||
await db.flush()
|
||||
return docs
|
||||
|
||||
|
||||
# ── the MATCH expression ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_match_query_quotes_every_term_and_prefixes_it():
|
||||
assert doc_search.build_match_query("nas backup") == '"nas"* AND "backup"*'
|
||||
|
||||
|
||||
def test_match_query_neutralises_punctuation_a_user_types():
|
||||
# An IP and a lone quote are data, not FTS5 syntax.
|
||||
assert doc_search.build_match_query("192.168.1.1") == '"192.168.1.1"*'
|
||||
assert doc_search.build_match_query('a"b') == '"a""b"*'
|
||||
|
||||
|
||||
def test_match_query_of_whitespace_is_empty():
|
||||
assert doc_search.build_match_query(" ") == ""
|
||||
|
||||
|
||||
def test_tags_text_flattens_a_list():
|
||||
assert doc_search.tags_text(["a", "b"]) == "a b"
|
||||
assert doc_search.tags_text(None) == ""
|
||||
|
||||
|
||||
# ── FTS5 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_fts_is_available_when_the_index_table_exists(db_session: AsyncSession):
|
||||
assert await doc_search.fts_available(db_session) is True
|
||||
|
||||
|
||||
async def test_search_finds_a_word_that_is_only_in_a_body(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
engine, hits = await doc_search.search(db_session, "Synology")
|
||||
assert engine == "fts5"
|
||||
assert len(hits) == 1
|
||||
assert "Synology" in hits[0]["snippet"]
|
||||
|
||||
|
||||
async def test_search_matches_a_prefix(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
_, hits = await doc_search.search(db_session, "OPNsen")
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
async def test_search_matches_a_title(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
_, hits = await doc_search.search(db_session, "VLAN")
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
async def test_search_matches_a_tag(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
_, hits = await doc_search.search(db_session, "network")
|
||||
assert len(hits) == 2
|
||||
|
||||
|
||||
async def test_search_ands_its_terms(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
_, hits = await doc_search.search(db_session, "guest traffic")
|
||||
assert len(hits) == 1
|
||||
_, none = await doc_search.search(db_session, "guest Synology")
|
||||
assert none == []
|
||||
|
||||
|
||||
async def test_search_of_an_empty_query_returns_nothing(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
_, hits = await doc_search.search(db_session, " ")
|
||||
assert hits == []
|
||||
|
||||
|
||||
async def test_reindexing_replaces_the_previous_body(db_session: AsyncSession):
|
||||
docs = await _seed(db_session)
|
||||
docs[0].body = "Replaced entirely."
|
||||
await doc_search.index_document(db_session, docs[0])
|
||||
_, gone = await doc_search.search(db_session, "Synology")
|
||||
assert gone == []
|
||||
_, found = await doc_search.search(db_session, "Replaced")
|
||||
assert len(found) == 1
|
||||
|
||||
|
||||
async def test_unindexing_removes_the_document(db_session: AsyncSession):
|
||||
docs = await _seed(db_session)
|
||||
await doc_search.unindex_document(db_session, docs[0].id)
|
||||
_, hits = await doc_search.search(db_session, "Synology")
|
||||
assert hits == []
|
||||
|
||||
|
||||
async def test_the_limit_is_honoured(db_session: AsyncSession):
|
||||
for i in range(8):
|
||||
doc = _doc(title=f"D{i}", slug=f"d{i}", body="shared word here")
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
await doc_search.index_document(db_session, doc)
|
||||
_, hits = await doc_search.search(db_session, "shared", limit=3)
|
||||
assert len(hits) == 3
|
||||
|
||||
|
||||
# ── the LIKE fallback ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_search_falls_back_to_like_without_fts5(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
await db_session.execute(text("DROP TABLE documents_fts"))
|
||||
doc_search.reset_availability_cache()
|
||||
|
||||
engine, hits = await doc_search.search(db_session, "Synology")
|
||||
assert engine == "like"
|
||||
assert len(hits) == 1
|
||||
assert "Synology" in hits[0]["snippet"]
|
||||
|
||||
|
||||
async def test_indexing_is_a_no_op_without_fts5(db_session: AsyncSession):
|
||||
await db_session.execute(text("DROP TABLE documents_fts"))
|
||||
doc_search.reset_availability_cache()
|
||||
doc = _doc(title="NAS", slug="nas", body="body")
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
# Must not raise — writes carry on, only search degrades.
|
||||
await doc_search.index_document(db_session, doc)
|
||||
await doc_search.unindex_document(db_session, doc.id)
|
||||
|
||||
|
||||
async def test_like_fallback_is_case_insensitive(db_session: AsyncSession):
|
||||
await _seed(db_session)
|
||||
await db_session.execute(text("DROP TABLE documents_fts"))
|
||||
doc_search.reset_availability_cache()
|
||||
_, hits = await doc_search.search(db_session, "synology")
|
||||
assert len(hits) == 1
|
||||
|
||||
|
||||
def test_excerpt_centres_on_the_match():
|
||||
body = "x" * 200 + "needle" + "y" * 200
|
||||
excerpt = doc_search._excerpt(body, "needle")
|
||||
assert "needle" in excerpt
|
||||
assert excerpt.startswith("…") and excerpt.endswith("…")
|
||||
|
||||
|
||||
def test_excerpt_falls_back_to_the_head_when_there_is_no_match():
|
||||
assert doc_search._excerpt("short body", "absent") == "short body"
|
||||
@@ -0,0 +1,392 @@
|
||||
"""The generated skeleton. Written once, then owned by the user — so what it
|
||||
emits on day one is the whole contract."""
|
||||
|
||||
from datetime import date, datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services import doc_template as t
|
||||
|
||||
|
||||
def _device(**overrides):
|
||||
base = dict(
|
||||
id="dev-1",
|
||||
label="nas-01",
|
||||
friendly_name=None,
|
||||
hostname="nas-01.lan",
|
||||
ip="192.168.1.20",
|
||||
mac="00:11:32:aa:bb:cc",
|
||||
ieee_address=None,
|
||||
os="DSM 7.2",
|
||||
vendor="Synology",
|
||||
model="DS920+",
|
||||
type="nas",
|
||||
suggested_type=None,
|
||||
services=[
|
||||
{"port": 5000, "protocol": "tcp", "service_name": "Synology DSM", "category": "management"},
|
||||
{"port": 445, "protocol": "tcp", "service_name": "SMB"},
|
||||
],
|
||||
properties=[{"key": "serial", "value": "20C0PDN", "icon": None, "visible": True}],
|
||||
cpu_count=4,
|
||||
cpu_model="Intel Celeron J4125",
|
||||
ram_gb=20.0,
|
||||
disk_gb=32000.0,
|
||||
check_method="http",
|
||||
check_target="http://192.168.1.20:5000",
|
||||
discovery_source="arp",
|
||||
discovery_sources=["arp", "mdns"],
|
||||
discovered_at=datetime(2026, 3, 4, tzinfo=timezone.utc),
|
||||
notes="",
|
||||
)
|
||||
base.update(overrides)
|
||||
return SimpleNamespace(**base)
|
||||
|
||||
|
||||
# ── cells and names ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_cell_escapes_pipes_and_collapses_newlines():
|
||||
assert t.cell("a|b") == r"a\|b"
|
||||
assert t.cell("one\ntwo") == "one two"
|
||||
assert t.cell(None) == "—"
|
||||
assert t.cell("") == "—"
|
||||
|
||||
|
||||
def test_cell_escapes_backslash_before_pipe():
|
||||
# Backslash first, or the escape of the pipe would itself be escaped.
|
||||
assert t.cell(r"a\|b") == r"a\\\|b"
|
||||
|
||||
|
||||
def test_device_name_falls_back_through_label_hostname_ip():
|
||||
assert t.device_name(_device()) == "nas-01"
|
||||
assert t.device_name(_device(label=None)) == "nas-01.lan"
|
||||
assert t.device_name(_device(label=None, hostname=None)) == "192.168.1.20"
|
||||
assert t.device_name(_device(label=None, hostname=None, ip=None)) == "device"
|
||||
|
||||
|
||||
def test_device_type_prefers_curated_over_guessed():
|
||||
assert t.device_type(_device(type="nas", suggested_type="server")) == "nas"
|
||||
assert t.device_type(_device(type=None, suggested_type="server")) == "server"
|
||||
|
||||
|
||||
# ── blocks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_device_info_block_reads_every_address():
|
||||
md = t.block_device_info(_device())
|
||||
assert "| Hostname | nas-01.lan |" in md
|
||||
assert "| IP | 192.168.1.20 |" in md
|
||||
assert "| Vendor / Model | Synology / DS920+ |" in md
|
||||
assert "`http` → `http://192.168.1.20:5000`" in md
|
||||
assert "(arp, mdns)" in md
|
||||
|
||||
|
||||
def test_device_info_block_prints_dashes_for_missing_facts():
|
||||
md = t.block_device_info(_device(hostname=None, mac=None, os=None, vendor=None, model=None))
|
||||
assert "| Hostname | — |" in md
|
||||
assert "| Vendor / Model | — |" in md
|
||||
|
||||
|
||||
def test_hardware_block_joins_cpu_model_and_count():
|
||||
assert "Intel Celeron J4125 ×4" in t.block_hardware(_device())
|
||||
assert "20 GB" in t.block_hardware(_device())
|
||||
|
||||
|
||||
def test_hardware_block_handles_a_count_without_a_model():
|
||||
assert "4 cores" in t.block_hardware(_device(cpu_model=None))
|
||||
|
||||
|
||||
# ── hardware comes out of `properties` first ────────────────────────────────
|
||||
|
||||
# What the Proxmox import and the scanner actually write.
|
||||
_PROXMOX_PROPERTIES = [
|
||||
{"key": "VMID", "value": "108"},
|
||||
{"key": "Kind", "value": "LXC"},
|
||||
{"key": "CPU Cores", "value": "4"},
|
||||
{"key": "RAM", "value": "6.1 GB"},
|
||||
{"key": "Disk", "value": "50.0 GB"},
|
||||
]
|
||||
|
||||
|
||||
def _from_properties(**overrides):
|
||||
"""A row as the importers leave it: hardware in properties, columns empty."""
|
||||
return _device(
|
||||
**{
|
||||
"properties": _PROXMOX_PROPERTIES,
|
||||
"cpu_count": None,
|
||||
"cpu_model": None,
|
||||
"ram_gb": None,
|
||||
"disk_gb": None,
|
||||
**overrides,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_hardware_block_reads_the_properties_when_the_columns_are_empty():
|
||||
"""The regression: an empty hardware table beside a full properties one.
|
||||
|
||||
`cpu_count` / `ram_gb` / `disk_gb` are barely written any more — the same
|
||||
facts arrive as properties — so the table printed three em dashes next to a
|
||||
properties table listing all three.
|
||||
"""
|
||||
md = t.block_hardware(_from_properties())
|
||||
assert "| 4 cores | 6.1 GB | 50.0 GB |" in md
|
||||
assert "—" not in md
|
||||
|
||||
|
||||
def test_hardware_block_takes_a_property_value_verbatim():
|
||||
# "6.1 GB" is already formatted; appending a unit would say "6.1 GB GB", and
|
||||
# guessing one onto a bare number would be inventing a fact.
|
||||
md = t.block_hardware(_from_properties(properties=[{"key": "RAM", "value": "12288 MB"}]))
|
||||
assert "| 12288 MB |" in md
|
||||
|
||||
|
||||
def test_hardware_block_matches_property_keys_whatever_their_case():
|
||||
md = t.block_hardware(_from_properties(properties=[{"key": "ram", "value": "8 GB"}]))
|
||||
assert "8 GB" in md
|
||||
|
||||
|
||||
def test_hardware_block_accepts_the_common_synonyms():
|
||||
md = t.block_hardware(
|
||||
_from_properties(
|
||||
properties=[
|
||||
{"key": "Processor", "value": "Xeon E3-1220"},
|
||||
{"key": "vCPU", "value": "8"},
|
||||
{"key": "Memory", "value": "64 GB"},
|
||||
{"key": "Storage", "value": "4 TB"},
|
||||
]
|
||||
)
|
||||
)
|
||||
assert "| Xeon E3-1220 ×8 | 64 GB | 4 TB |" in md
|
||||
|
||||
|
||||
def test_hardware_block_prefers_a_property_over_the_legacy_column():
|
||||
md = t.block_hardware(_device(properties=[{"key": "RAM", "value": "64 GB"}]))
|
||||
assert "64 GB" in md
|
||||
assert "20 GB" not in md
|
||||
|
||||
|
||||
def test_hardware_block_still_falls_back_to_the_columns():
|
||||
# A row that predates the move, or one typed into the inventory modal.
|
||||
md = t.block_hardware(_device(properties=[]))
|
||||
assert "Intel Celeron J4125 ×4" in md
|
||||
assert "20 GB" in md
|
||||
|
||||
|
||||
def test_hardware_block_is_empty_when_neither_source_has_anything():
|
||||
md = t.block_hardware(
|
||||
_device(properties=[], cpu_count=None, cpu_model=None, ram_gb=None, disk_gb=None)
|
||||
)
|
||||
assert "| — | — | — |" in md
|
||||
|
||||
|
||||
def test_hardware_block_ignores_a_property_with_no_value():
|
||||
md = t.block_hardware(_from_properties(properties=[{"key": "RAM", "value": ""}]))
|
||||
assert "| — | — | — |" in md
|
||||
|
||||
|
||||
def test_property_map_keeps_the_first_of_a_repeated_key():
|
||||
device = _device(properties=[{"key": "RAM", "value": "first"}, {"key": "RAM", "value": "second"}])
|
||||
assert t.property_map(device)["ram"] == "first"
|
||||
|
||||
|
||||
# ── the snapshot follows what the tables print ──────────────────────────────
|
||||
|
||||
|
||||
def test_the_snapshot_carries_the_properties():
|
||||
snapshot = t.facts_snapshot(_from_properties())
|
||||
assert snapshot["properties"]["RAM"] == "6.1 GB"
|
||||
|
||||
|
||||
def test_editing_a_property_is_drift():
|
||||
before = t.facts_snapshot(_from_properties())
|
||||
after = t.facts_snapshot(
|
||||
_from_properties(properties=[*_PROXMOX_PROPERTIES[:3], {"key": "RAM", "value": "12 GB"}])
|
||||
)
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_the_snapshot_survives_its_json_round_trip():
|
||||
"""It is stored as JSON and compared against a freshly computed one.
|
||||
|
||||
A tuple or a set would come back a list and every document would read as
|
||||
drifted forever, so the shape has to be JSON-native on both sides.
|
||||
"""
|
||||
import json
|
||||
|
||||
snapshot = t.facts_snapshot(_from_properties())
|
||||
assert json.loads(json.dumps(snapshot)) == snapshot
|
||||
|
||||
|
||||
def test_services_block_writes_one_section_per_service():
|
||||
md = t.block_services(_device())
|
||||
assert "### Synology DSM — `5000/tcp`" in md
|
||||
assert "### SMB — `445/tcp`" in md
|
||||
assert "**Category** — management" in md
|
||||
# Prompts, not values — the user fills these in.
|
||||
assert md.count("**Purpose**") == 2
|
||||
|
||||
|
||||
def test_services_block_says_so_when_nothing_was_fingerprinted():
|
||||
assert "No service" in t.block_services(_device(services=[]))
|
||||
|
||||
|
||||
def test_services_block_never_invents_a_url_for_a_non_http_port():
|
||||
md = t.block_services(_device(services=[{"port": 445, "protocol": "tcp", "service_name": "SMB"}]))
|
||||
assert "**URL**" not in md
|
||||
|
||||
|
||||
def test_services_block_builds_an_http_url_from_the_device_ip():
|
||||
assert "<http://192.168.1.20:5000>" in t.block_services(_device())
|
||||
|
||||
|
||||
def test_properties_block_is_empty_when_the_device_has_none():
|
||||
assert t.block_properties(_device(properties=[])) == ""
|
||||
assert "| serial | 20C0PDN |" in t.block_properties(_device())
|
||||
|
||||
|
||||
def test_rack_block_prints_the_span_of_units():
|
||||
md = t.block_rack(_device(), rack={"name": "Rack-A", "u_start": 12, "u_height": 3, "col_span": 12})
|
||||
assert md == "Rack **Rack-A**, U12–U14, full width."
|
||||
|
||||
|
||||
def test_rack_block_collapses_a_single_unit():
|
||||
md = t.block_rack(_device(), rack={"name": "Rack-A", "u_start": 7, "u_height": 1, "col_span": 6})
|
||||
assert "U7," in md and "–" not in md
|
||||
assert "6/12 wide" in md
|
||||
|
||||
|
||||
def test_rack_block_is_empty_when_the_device_is_neither_racked_nor_zoned():
|
||||
assert t.block_rack(_device()) == ""
|
||||
assert t.block_rack(_device(), zone_label="Garage") == "Zone **Garage**."
|
||||
|
||||
|
||||
def test_network_block_guesses_the_slash_24_and_lists_peers():
|
||||
md = t.block_network(_device(), connections=["switch-core", "router"])
|
||||
assert "192.168.1.0/24" in md
|
||||
assert "`switch-core`, `router`" in md
|
||||
|
||||
|
||||
def test_network_block_takes_the_first_ip_of_a_multi_ip_device():
|
||||
assert "10.0.0.0/24" in t.block_network(_device(ip="10.0.0.5, 192.168.1.20"))
|
||||
|
||||
|
||||
def test_network_block_survives_a_device_with_no_ip():
|
||||
assert "**Subnet / VLAN** — _…_" in t.block_network(_device(ip=None))
|
||||
|
||||
|
||||
def test_render_block_rejects_an_unknown_name():
|
||||
try:
|
||||
t.render_block("nope", _device())
|
||||
except ValueError as exc:
|
||||
assert "Unknown block" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError")
|
||||
|
||||
|
||||
# ── the whole document ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_device_document_carries_every_section():
|
||||
md = t.render_device_document(_device(), today=date(2026, 9, 5))
|
||||
for heading in (
|
||||
"## Device Information",
|
||||
"### Hardware",
|
||||
"## Services",
|
||||
"## Network",
|
||||
"## Configuration",
|
||||
"## Operations",
|
||||
"## Troubleshooting",
|
||||
"## Dependencies",
|
||||
"## Changelog",
|
||||
"## Notes",
|
||||
):
|
||||
assert heading in md, heading
|
||||
|
||||
|
||||
def test_device_document_opens_with_versioned_frontmatter():
|
||||
md = t.render_device_document(_device(), today=date(2026, 9, 5))
|
||||
assert md.startswith("---\n")
|
||||
assert f"homelable: {t.FRONTMATTER_VERSION}" in md
|
||||
assert "device: dev-1" in md
|
||||
assert "created: 2026-09-05" in md
|
||||
assert "review_every: 6m" in md
|
||||
assert "tags: []" in md
|
||||
|
||||
|
||||
def test_library_document_advertises_tags_too():
|
||||
assert "tags: []" in t.render_library_document(t.TEMPLATE_RUNBOOK, "Reboot the NAS")
|
||||
|
||||
|
||||
def test_device_document_appends_old_notes_verbatim_with_provenance():
|
||||
notes = "Runs the backups.\n\n Indented line kept as typed | with a pipe."
|
||||
md = t.render_device_document(_device(notes=notes), today=date(2026, 9, 5))
|
||||
assert "<!-- Migrated verbatim from this device's Notes field on 2026-09-05. -->" in md
|
||||
assert notes.strip() in md
|
||||
|
||||
|
||||
def test_device_document_prompts_when_there_were_no_notes():
|
||||
md = t.render_device_document(_device(notes=""))
|
||||
assert "Migrated verbatim" not in md
|
||||
assert "_Anything worth remembering about this device._" in md
|
||||
|
||||
|
||||
def test_device_document_drops_the_location_section_when_there_is_no_place():
|
||||
assert "### Physical location" not in t.render_device_document(_device())
|
||||
md = t.render_device_document(_device(), zone_label="Garage")
|
||||
assert "### Physical location" in md
|
||||
|
||||
|
||||
def test_device_document_drops_the_properties_section_when_there_are_none():
|
||||
assert "### Properties" not in t.render_device_document(_device(properties=[]))
|
||||
|
||||
|
||||
def test_device_document_of_a_bare_device_still_renders():
|
||||
bare = SimpleNamespace(id="d", label=None, notes=None)
|
||||
md = t.render_device_document(bare)
|
||||
assert "# device" in md
|
||||
assert "## Services" in md
|
||||
|
||||
|
||||
# ── library templates ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_runbook_template_has_its_headings():
|
||||
md = t.render_library_document(t.TEMPLATE_RUNBOOK, "Reboot the NAS", today=date(2026, 9, 5))
|
||||
assert "# Reboot the NAS" in md
|
||||
assert "## Rollback" in md
|
||||
assert "template: runbook" in md
|
||||
|
||||
|
||||
def test_blank_template_is_just_a_title():
|
||||
md = t.render_library_document(t.TEMPLATE_BLANK, "Scratch")
|
||||
assert "# Scratch" in md
|
||||
assert "##" not in md
|
||||
|
||||
|
||||
def test_unknown_template_falls_back_to_blank_rather_than_failing():
|
||||
md = t.render_library_document("does-not-exist", "Scratch")
|
||||
assert "# Scratch" in md
|
||||
|
||||
|
||||
def test_frontmatter_omits_empty_scalars_but_prints_an_empty_list():
|
||||
# `tags: []` is how a user learns tags exist at all, so an empty list stays.
|
||||
rendered = t.render_frontmatter({"a": 1, "b": None, "c": "", "d": [], "e": ["x", "y"]})
|
||||
assert rendered == "---\na: 1\nd: []\ne: [x, y]\n---"
|
||||
|
||||
|
||||
# ── the drift snapshot ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_facts_snapshot_keys_services_by_port_protocol_name():
|
||||
snapshot = t.facts_snapshot(_device())
|
||||
assert snapshot["services"] == ["445|tcp|smb", "5000|tcp|synology dsm"]
|
||||
|
||||
|
||||
def test_facts_snapshot_is_stable_when_services_are_reordered():
|
||||
a = t.facts_snapshot(_device())
|
||||
b = t.facts_snapshot(_device(services=list(reversed(_device().services))))
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_facts_snapshot_changes_when_the_ip_moves():
|
||||
assert t.facts_snapshot(_device()) != t.facts_snapshot(_device(ip="192.168.1.21"))
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Slugs, frontmatter parsing, and the two tree guards."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Document
|
||||
from app.services import doc_tree
|
||||
|
||||
|
||||
def _doc(**kwargs) -> Document:
|
||||
base = dict(kind="folder", title="T", slug="t", body="")
|
||||
base.update(kwargs)
|
||||
return Document(**base)
|
||||
|
||||
|
||||
# ── slugs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_slugify_lowercases_and_collapses_punctuation():
|
||||
assert doc_tree.slugify("NAS-01 / main storage!") == "nas-01-main-storage"
|
||||
|
||||
|
||||
def test_slugify_of_nothing_is_untitled():
|
||||
assert doc_tree.slugify("") == "untitled"
|
||||
assert doc_tree.slugify(" ") == "untitled"
|
||||
assert doc_tree.slugify("!!!") == "untitled"
|
||||
|
||||
|
||||
def test_slugify_truncates_a_long_title():
|
||||
assert len(doc_tree.slugify("x" * 200)) == 80
|
||||
|
||||
|
||||
async def test_unique_slug_suffixes_a_taken_sibling(db_session: AsyncSession):
|
||||
db_session.add(_doc(title="Network", slug="network"))
|
||||
await db_session.flush()
|
||||
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-2"
|
||||
|
||||
|
||||
async def test_unique_slug_keeps_counting_past_the_second(db_session: AsyncSession):
|
||||
db_session.add(_doc(slug="network"))
|
||||
db_session.add(_doc(slug="network-2"))
|
||||
await db_session.flush()
|
||||
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network-3"
|
||||
|
||||
|
||||
async def test_unique_slug_only_looks_at_siblings(db_session: AsyncSession):
|
||||
parent = _doc(title="Folder", slug="folder")
|
||||
db_session.add(parent)
|
||||
await db_session.flush()
|
||||
db_session.add(_doc(slug="network", parent_id=parent.id))
|
||||
await db_session.flush()
|
||||
# Same slug, different parent — no clash.
|
||||
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None) == "network"
|
||||
|
||||
|
||||
async def test_unique_slug_ignores_the_document_being_renamed(db_session: AsyncSession):
|
||||
doc = _doc(title="Network", slug="network")
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
assert await doc_tree.unique_slug(db_session, "Network", parent_id=None, exclude_id=doc.id) == "network"
|
||||
|
||||
|
||||
# ── frontmatter ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_frontmatter_reads_the_leading_block():
|
||||
body = "---\ntitle: NAS\ntags: [a, b]\n---\n\n# NAS\n"
|
||||
assert doc_tree.parse_frontmatter(body) == {"title": "NAS", "tags": ["a", "b"]}
|
||||
|
||||
|
||||
def test_parse_frontmatter_ignores_a_block_that_is_not_first():
|
||||
assert doc_tree.parse_frontmatter("# Title\n\n---\ntitle: NAS\n---\n") == {}
|
||||
|
||||
|
||||
def test_parse_frontmatter_of_malformed_yaml_is_empty_not_an_error():
|
||||
# The user is mid-edit; the cache goes stale, the save does not fail.
|
||||
assert doc_tree.parse_frontmatter("---\ntitle: [unclosed\n---\nbody") == {}
|
||||
|
||||
|
||||
def test_parse_frontmatter_of_a_non_mapping_is_empty():
|
||||
assert doc_tree.parse_frontmatter("---\n- a\n- b\n---\n") == {}
|
||||
|
||||
|
||||
def test_parse_frontmatter_of_an_empty_body_is_empty():
|
||||
assert doc_tree.parse_frontmatter("") == {}
|
||||
|
||||
|
||||
def test_parse_frontmatter_keeps_a_trailing_space_on_the_fence():
|
||||
assert doc_tree.parse_frontmatter("--- \ntitle: NAS\n--- \n") == {"title": "NAS"}
|
||||
|
||||
|
||||
def test_parse_frontmatter_of_an_unclosed_block_does_not_go_quadratic():
|
||||
"""A body that opens a block and never closes it must still be linear.
|
||||
|
||||
The shape a document has while the block is being typed, and the input the
|
||||
scanner named: `---\n` then many repetitions of `\n `. With `\s*` around the
|
||||
fences the closing alternative was reachable two ways and each added line
|
||||
multiplied the backtracking.
|
||||
"""
|
||||
import time
|
||||
|
||||
body = "---\n" + "\n " * 40_000
|
||||
started = time.perf_counter()
|
||||
assert doc_tree.parse_frontmatter(body) == {}
|
||||
assert time.perf_counter() - started < 1.0
|
||||
|
||||
|
||||
def test_tags_accepts_a_list_or_a_comma_string():
|
||||
assert doc_tree.tags_from({"tags": ["a", " b "]}) == ["a", "b"]
|
||||
assert doc_tree.tags_from({"tags": "a, b"}) == ["a", "b"]
|
||||
assert doc_tree.tags_from({"tags": 7}) == []
|
||||
assert doc_tree.tags_from({}) == []
|
||||
|
||||
|
||||
# ── tree guards ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _chain(db_session: AsyncSession, depth: int) -> list[Document]:
|
||||
docs: list[Document] = []
|
||||
parent_id = None
|
||||
for i in range(depth):
|
||||
doc = _doc(title=f"L{i}", slug=f"l{i}", parent_id=parent_id)
|
||||
db_session.add(doc)
|
||||
await db_session.flush()
|
||||
docs.append(doc)
|
||||
parent_id = doc.id
|
||||
return docs
|
||||
|
||||
|
||||
async def test_is_ancestor_walks_the_whole_chain(db_session: AsyncSession):
|
||||
a, b, c = await _chain(db_session, 3)
|
||||
assert await doc_tree.is_ancestor(db_session, a.id, c.id)
|
||||
assert not await doc_tree.is_ancestor(db_session, c.id, a.id)
|
||||
|
||||
|
||||
async def test_a_document_is_its_own_ancestor(db_session: AsyncSession):
|
||||
(a,) = await _chain(db_session, 1)
|
||||
assert await doc_tree.is_ancestor(db_session, a.id, a.id)
|
||||
|
||||
|
||||
async def test_subtree_ids_collects_every_descendant(db_session: AsyncSession):
|
||||
a, b, c = await _chain(db_session, 3)
|
||||
sibling = _doc(title="S", slug="s", parent_id=a.id)
|
||||
db_session.add(sibling)
|
||||
await db_session.flush()
|
||||
ids = await doc_tree.subtree_ids(db_session, a.id)
|
||||
assert set(ids) == {a.id, b.id, c.id, sibling.id}
|
||||
# Root first, so a caller deleting in reverse removes leaves first.
|
||||
assert ids[0] == a.id
|
||||
|
||||
|
||||
async def test_subtree_ids_of_a_leaf_is_just_itself(db_session: AsyncSession):
|
||||
(a,) = await _chain(db_session, 1)
|
||||
assert await doc_tree.subtree_ids(db_session, a.id) == [a.id]
|
||||
@@ -0,0 +1,774 @@
|
||||
"""The documents API.
|
||||
|
||||
Covers what the plan promises the user: a document per device generated once,
|
||||
a Library tree that cannot be knotted, history that survives a restore, and a
|
||||
document that outlives the device it describes.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def _device(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
payload = {"label": "nas-01", "hostname": "nas-01.lan", "ip": "192.168.1.20", "discovery_source": "manual", **body}
|
||||
res = await client.post("/api/v1/scan/pending", json=payload, headers=headers)
|
||||
assert res.status_code in (200, 201), res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
||||
res = await client.post("/api/v1/documents", json={"title": "Page", **body}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()
|
||||
|
||||
|
||||
async def _design(client: AsyncClient, headers: dict) -> str:
|
||||
res = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
|
||||
assert res.status_code == 201, res.text
|
||||
return res.json()["id"]
|
||||
|
||||
|
||||
# ── auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_list_requires_auth(client: AsyncClient):
|
||||
assert (await client.get("/api/v1/documents")).status_code == 401
|
||||
|
||||
|
||||
async def test_create_requires_auth(client: AsyncClient):
|
||||
assert (await client.post("/api/v1/documents", json={"title": "X"})).status_code == 401
|
||||
|
||||
|
||||
async def test_search_requires_auth(client: AsyncClient):
|
||||
assert (await client.get("/api/v1/documents/search?q=x")).status_code == 401
|
||||
|
||||
|
||||
async def test_coverage_requires_auth(client: AsyncClient):
|
||||
assert (await client.get("/api/v1/documents/coverage")).status_code == 401
|
||||
|
||||
|
||||
async def test_delete_requires_auth(client: AsyncClient):
|
||||
assert (await client.delete("/api/v1/documents/x")).status_code == 401
|
||||
|
||||
|
||||
# ── create ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_create_a_blank_page(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="VLAN plan")
|
||||
assert doc["kind"] == "page"
|
||||
assert doc["slug"] == "vlan-plan"
|
||||
assert "# VLAN plan" in doc["body"]
|
||||
|
||||
|
||||
async def test_create_a_page_from_a_template(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Reboot the NAS", template_id="runbook")
|
||||
assert "## Rollback" in doc["body"]
|
||||
assert doc["template_id"] == "runbook"
|
||||
|
||||
|
||||
async def test_create_a_page_with_an_explicit_body(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Raw", body="---\ntags: [a]\n---\n\nhello")
|
||||
assert doc["body"].endswith("hello")
|
||||
# The frontmatter cache is derived from the body, never sent on its own.
|
||||
assert doc["frontmatter"] == {"tags": ["a"]}
|
||||
assert doc["tags"] == ["a"]
|
||||
|
||||
|
||||
async def test_create_a_folder(client: AsyncClient, headers: dict):
|
||||
folder = await _create(client, headers, title="Runbooks", kind="folder")
|
||||
child = await _create(client, headers, title="Reboot", parent_id=folder["id"])
|
||||
assert child["parent_id"] == folder["id"]
|
||||
|
||||
|
||||
async def test_create_rejects_an_unknown_kind(client: AsyncClient, headers: dict):
|
||||
res = await client.post("/api/v1/documents", json={"title": "X", "kind": "wat"}, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
async def test_create_rejects_an_unknown_template(client: AsyncClient, headers: dict):
|
||||
res = await client.post("/api/v1/documents", json={"title": "X", "template_id": "wat"}, headers=headers)
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
async def test_create_rejects_a_missing_parent(client: AsyncClient, headers: dict):
|
||||
res = await client.post(
|
||||
"/api/v1/documents", json={"title": "X", "parent_id": str(uuid.uuid4())}, headers=headers
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_create_rejects_a_page_that_also_names_a_device(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
res = await client.post(
|
||||
"/api/v1/documents", json={"title": "X", "kind": "page", "device_id": device["id"]}, headers=headers
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_create_rejects_a_device_document_naming_nothing(client: AsyncClient, headers: dict):
|
||||
res = await client.post("/api/v1/documents", json={"title": "X", "kind": "device"}, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_create_rejects_two_links_at_once(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
res = await client.post(
|
||||
"/api/v1/documents",
|
||||
json={"title": "X", "kind": "device", "device_id": device["id"], "design_id": await _design(client, headers)},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_create_rejects_a_device_that_does_not_exist(client: AsyncClient, headers: dict):
|
||||
res = await client.post(
|
||||
"/api/v1/documents",
|
||||
json={"title": "X", "kind": "device", "device_id": str(uuid.uuid4())},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_a_device_gets_at_most_one_document(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
body = {"title": "nas", "kind": "device", "device_id": device["id"]}
|
||||
assert (await client.post("/api/v1/documents", json=body, headers=headers)).status_code == 201
|
||||
second = await client.post("/api/v1/documents", json=body, headers=headers)
|
||||
assert second.status_code == 409
|
||||
|
||||
|
||||
async def test_a_sibling_title_gets_a_distinct_slug(client: AsyncClient, headers: dict):
|
||||
first = await _create(client, headers, title="Network")
|
||||
second = await _create(client, headers, title="Network")
|
||||
assert first["slug"] == "network"
|
||||
assert second["slug"] == "network-2"
|
||||
|
||||
|
||||
# ── the generated device document ───────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_device_document_is_scaffolded_from_the_live_facts(client: AsyncClient, headers: dict):
|
||||
device = await _device(
|
||||
client, headers, hostname="nas-01.lan", vendor="Synology", notes="Holds the backups."
|
||||
)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
assert "| Hostname | nas-01.lan |" in doc["body"]
|
||||
assert "| IP | 192.168.1.20 |" in doc["body"]
|
||||
assert "Holds the backups." in doc["body"]
|
||||
assert doc["facts_snapshot"]["ip"] == "192.168.1.20"
|
||||
assert doc["facts_synced_at"] is not None
|
||||
|
||||
|
||||
async def test_the_old_notes_column_is_left_untouched_by_scaffolding(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers, notes="Holds the backups.")
|
||||
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
row = next(d for d in res.json() if d["id"] == device["id"])
|
||||
assert row["notes"] == "Holds the backups."
|
||||
|
||||
|
||||
async def test_a_device_document_records_its_zone_and_neighbours(client: AsyncClient, headers: dict):
|
||||
design_id = await _design(client, headers)
|
||||
device = await _device(client, headers)
|
||||
zone = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "groupRect", "label": "Garage", "design_id": design_id, "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
zone_id = zone.json()["id"]
|
||||
# A node binds to its inventory row by its addresses, not by an id on the
|
||||
# payload — the canvas is one more way to document hardware, not a store.
|
||||
node = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={
|
||||
"type": "nas",
|
||||
"label": "nas-01",
|
||||
"design_id": design_id,
|
||||
"ip": "192.168.1.20",
|
||||
"hostname": "nas-01.lan",
|
||||
"parent_id": zone_id,
|
||||
"pos_x": 0,
|
||||
"pos_y": 0,
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert node.json()["device_id"] == device["id"], node.text
|
||||
peer = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "switch-core", "design_id": design_id, "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": node.json()["id"], "target": peer.json()["id"], "type": "ethernet"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
assert "Zone **Garage**." in doc["body"]
|
||||
assert "`switch-core`" in doc["body"]
|
||||
|
||||
|
||||
# ── blocks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_block_can_be_regenerated_on_demand(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers, hostname="nas-01.lan")
|
||||
res = await client.get(
|
||||
f"/api/v1/documents/blocks?block=device-info&device_id={device['id']}", headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert "| Hostname | nas-01.lan |" in res.json()["markdown"]
|
||||
|
||||
|
||||
async def test_an_unknown_block_is_rejected(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
res = await client.get(f"/api/v1/documents/blocks?block=nope&device_id={device['id']}", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_a_block_for_a_missing_device_is_404(client: AsyncClient, headers: dict):
|
||||
res = await client.get(
|
||||
f"/api/v1/documents/blocks?block=device-info&device_id={uuid.uuid4()}", headers=headers
|
||||
)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ── read / list ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_get_a_missing_document_is_404(client: AsyncClient, headers: dict):
|
||||
assert (await client.get(f"/api/v1/documents/{uuid.uuid4()}", headers=headers)).status_code == 404
|
||||
|
||||
|
||||
async def test_listing_carries_no_body(client: AsyncClient, headers: dict):
|
||||
await _create(client, headers, title="VLAN plan")
|
||||
row = (await client.get("/api/v1/documents", headers=headers)).json()[0]
|
||||
assert "body" not in row
|
||||
|
||||
|
||||
async def test_listing_filters_by_kind_and_parent(client: AsyncClient, headers: dict):
|
||||
folder = await _create(client, headers, title="Runbooks", kind="folder")
|
||||
await _create(client, headers, title="Reboot", parent_id=folder["id"])
|
||||
await _create(client, headers, title="Loose")
|
||||
|
||||
folders = (await client.get("/api/v1/documents?kind=folder", headers=headers)).json()
|
||||
assert [d["title"] for d in folders] == ["Runbooks"]
|
||||
|
||||
children = (await client.get(f"/api/v1/documents?parent_id={folder['id']}", headers=headers)).json()
|
||||
assert [d["title"] for d in children] == ["Reboot"]
|
||||
|
||||
|
||||
async def test_listing_filters_by_tag(client: AsyncClient, headers: dict):
|
||||
await _create(client, headers, title="A", body="---\ntags: [Network]\n---\n")
|
||||
await _create(client, headers, title="B", body="---\ntags: [storage]\n---\n")
|
||||
hits = (await client.get("/api/v1/documents?tag=network", headers=headers)).json()
|
||||
assert [d["title"] for d in hits] == ["A"]
|
||||
|
||||
|
||||
# ── update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_editing_the_body_refreshes_the_frontmatter_cache(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntags: [x]\n---\n\nnew"}, headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["tags"] == ["x"]
|
||||
|
||||
|
||||
async def test_the_body_renames_the_document(client: AsyncClient, headers: dict):
|
||||
"""`title:` in the frontmatter is the only place a document is named."""
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}",
|
||||
json={"body": "---\ntitle: SMB / CIFS\n---\n\n# SMB / CIFS\n"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["title"] == "SMB / CIFS"
|
||||
assert res.json()["slug"] == "smb-cifs"
|
||||
|
||||
|
||||
async def test_a_body_without_a_title_keeps_the_one_it_has(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntags: [x]\n---\n\nnew"}, headers=headers
|
||||
)
|
||||
assert res.json()["title"] == "Page"
|
||||
|
||||
|
||||
async def test_a_blank_title_in_the_body_is_ignored(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}", json={"body": "---\ntitle: ' '\n---\n\nnew"}, headers=headers
|
||||
)
|
||||
assert res.json()["title"] == "Page"
|
||||
|
||||
|
||||
async def test_an_explicit_title_wins_over_the_body_in_the_same_request(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}",
|
||||
json={"title": "Chosen", "body": "---\ntitle: From the body\n---\n\nnew"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.json()["title"] == "Chosen"
|
||||
|
||||
|
||||
async def test_a_device_document_renamed_in_its_body_keeps_its_device(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}",
|
||||
json={"body": "---\ntitle: The big NAS\n---\n\n# The big NAS\n"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.json()["title"] == "The big NAS"
|
||||
assert res.json()["device_id"] == device["id"]
|
||||
|
||||
|
||||
async def test_renaming_reslugs(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"title": "New name"}, headers=headers)
|
||||
assert res.json()["slug"] == "new-name"
|
||||
|
||||
|
||||
async def test_starring_and_reviewing(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page")
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}", json={"starred": True, "reviewed": True}, headers=headers
|
||||
)
|
||||
assert res.json()["starred"] is True
|
||||
assert res.json()["reviewed_at"] is not None
|
||||
|
||||
|
||||
async def test_patching_a_missing_document_is_404(client: AsyncClient, headers: dict):
|
||||
res = await client.patch(f"/api/v1/documents/{uuid.uuid4()}", json={"title": "X"}, headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_a_folder_cannot_be_moved_inside_itself(client: AsyncClient, headers: dict):
|
||||
outer = await _create(client, headers, title="Outer", kind="folder")
|
||||
inner = await _create(client, headers, title="Inner", kind="folder", parent_id=outer["id"])
|
||||
res = await client.patch(f"/api/v1/documents/{outer['id']}", json={"parent_id": inner["id"]}, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_a_document_can_only_be_filed_under_a_folder(client: AsyncClient, headers: dict):
|
||||
page = await _create(client, headers, title="Page")
|
||||
other = await _create(client, headers, title="Other")
|
||||
res = await client.patch(f"/api/v1/documents/{other['id']}", json={"parent_id": page["id"]}, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_a_device_document_does_not_live_in_the_library_tree(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
|
||||
folder = await _create(client, headers, title="F", kind="folder")
|
||||
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"parent_id": folder["id"]}, headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_resyncing_facts_clears_the_drift_without_touching_the_body(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
|
||||
await client.patch(f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers)
|
||||
|
||||
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"resync_facts": True}, headers=headers)
|
||||
assert res.json()["facts_snapshot"]["ip"] == "192.168.1.99"
|
||||
# The header the user owns still says what it always said.
|
||||
assert "192.168.1.20" in res.json()["body"]
|
||||
|
||||
|
||||
# ── drift ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_a_fresh_device_document_has_not_drifted(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
assert doc["drifted"] is False
|
||||
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
|
||||
assert read["drifted"] is False
|
||||
|
||||
|
||||
async def test_changing_the_device_marks_the_document_drifted(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
|
||||
)
|
||||
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
|
||||
assert read["drifted"] is True
|
||||
|
||||
|
||||
async def test_a_property_edit_alone_marks_the_document_drifted(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{device['id']}",
|
||||
json={"properties": [{"key": "Rack", "value": "A1"}]},
|
||||
headers=headers,
|
||||
)
|
||||
read = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
|
||||
assert read["drifted"] is True
|
||||
|
||||
|
||||
async def test_editing_the_body_does_not_make_a_document_drift(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
res = await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "my words"}, headers=headers)
|
||||
assert res.json()["drifted"] is False
|
||||
|
||||
|
||||
async def test_accepting_the_facts_clears_the_drift(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
|
||||
)
|
||||
res = await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}", json={"resync_facts": True}, headers=headers
|
||||
)
|
||||
assert res.json()["drifted"] is False
|
||||
|
||||
|
||||
async def test_regenerating_clears_the_drift(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
|
||||
)
|
||||
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
|
||||
assert res.json()["drifted"] is False
|
||||
|
||||
|
||||
async def test_the_listing_carries_the_drift_flag_for_the_tree_badge(
|
||||
client: AsyncClient, headers: dict
|
||||
):
|
||||
fresh = await _device(client, headers, label="switch-01", ip="192.168.1.2")
|
||||
stale = await _device(client, headers, label="nas-01", ip="192.168.1.20")
|
||||
in_sync = await _create(client, headers, title="switch-01", kind="device", device_id=fresh["id"])
|
||||
moved = await _create(client, headers, title="nas-01", kind="device", device_id=stale["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{stale['id']}", json={"ip": "192.168.1.99"}, headers=headers
|
||||
)
|
||||
|
||||
listing = (await client.get("/api/v1/documents", headers=headers)).json()
|
||||
by_id = {d["id"]: d for d in listing}
|
||||
assert by_id[moved["id"]]["drifted"] is True
|
||||
assert by_id[in_sync["id"]]["drifted"] is False
|
||||
|
||||
|
||||
async def test_a_library_page_never_drifts(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="VLAN plan")
|
||||
assert doc["drifted"] is False
|
||||
|
||||
|
||||
# ── revisions ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_an_edit_records_the_previous_body(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page", body="first")
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "second"}, headers=headers)
|
||||
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
|
||||
assert len(revisions) == 1
|
||||
assert revisions[0]["reason"] == "edit"
|
||||
stored = (await client.get(f"/api/v1/documents/revisions/{revisions[0]['id']}", headers=headers)).json()
|
||||
assert stored["body"] == "first"
|
||||
|
||||
|
||||
async def test_saving_an_unchanged_body_records_nothing(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page", body="same")
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "same"}, headers=headers)
|
||||
assert (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json() == []
|
||||
|
||||
|
||||
async def test_restoring_brings_back_an_old_body_and_is_itself_undoable(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page", body="first")
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "second"}, headers=headers)
|
||||
revision = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()[0]
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/documents/{doc['id']}/revisions/{revision['id']}/restore", headers=headers
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["body"] == "first"
|
||||
|
||||
history = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
|
||||
assert [r["reason"] for r in history][0] == "restore"
|
||||
|
||||
|
||||
async def test_restoring_brings_back_the_title_that_body_carried(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page", body="---\ntitle: First name\n---\n\nfirst")
|
||||
await client.patch(
|
||||
f"/api/v1/documents/{doc['id']}",
|
||||
json={"body": "---\ntitle: Second name\n---\n\nsecond"},
|
||||
headers=headers,
|
||||
)
|
||||
revision = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()[0]
|
||||
|
||||
res = await client.post(
|
||||
f"/api/v1/documents/{doc['id']}/revisions/{revision['id']}/restore", headers=headers
|
||||
)
|
||||
assert res.json()["title"] == "First name"
|
||||
|
||||
|
||||
async def test_restoring_a_revision_of_another_document_is_404(client: AsyncClient, headers: dict):
|
||||
a = await _create(client, headers, title="A", body="one")
|
||||
b = await _create(client, headers, title="B", body="one")
|
||||
await client.patch(f"/api/v1/documents/{a['id']}", json={"body": "two"}, headers=headers)
|
||||
revision = (await client.get(f"/api/v1/documents/{a['id']}/revisions", headers=headers)).json()[0]
|
||||
res = await client.post(f"/api/v1/documents/{b['id']}/revisions/{revision['id']}/restore", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
async def test_history_is_pruned_to_the_limit(client: AsyncClient, headers: dict):
|
||||
from app.services.doc_tree import REVISION_LIMIT
|
||||
|
||||
doc = await _create(client, headers, title="Page", body="v0")
|
||||
for i in range(1, REVISION_LIMIT + 6):
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": f"v{i}"}, headers=headers)
|
||||
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
|
||||
assert len(revisions) == REVISION_LIMIT
|
||||
|
||||
|
||||
# ── regenerate ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_regenerate_requires_auth(client: AsyncClient):
|
||||
assert (await client.post("/api/v1/documents/x/regenerate")).status_code == 401
|
||||
|
||||
|
||||
async def test_regenerating_a_device_document_rebuilds_it_from_the_facts(
|
||||
client: AsyncClient, headers: dict
|
||||
):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "everything I wrote"}, headers=headers)
|
||||
|
||||
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
body = res.json()
|
||||
assert "everything I wrote" not in body["body"]
|
||||
assert "192.168.1.20" in body["body"]
|
||||
# A regenerated document is only the template again, as it was on day one.
|
||||
assert body["edited_at"] is None
|
||||
assert body["template_id"] == "device"
|
||||
|
||||
|
||||
async def test_regenerating_reads_the_devices_current_facts(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
await client.patch(
|
||||
f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers
|
||||
)
|
||||
|
||||
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
|
||||
assert "192.168.1.99" in res.json()["body"]
|
||||
# Regenerating documents the device as it is now, so the drift is gone.
|
||||
assert res.json()["facts_snapshot"]["ip"] == "192.168.1.99"
|
||||
|
||||
|
||||
async def test_regenerating_keeps_the_replaced_body_in_the_history(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="VLAN plan", body="my own words")
|
||||
await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
|
||||
|
||||
revisions = (await client.get(f"/api/v1/documents/{doc['id']}/revisions", headers=headers)).json()
|
||||
assert revisions[0]["reason"] == "regenerate"
|
||||
stored = (await client.get(f"/api/v1/documents/revisions/{revisions[0]['id']}", headers=headers)).json()
|
||||
assert stored["body"] == "my own words"
|
||||
|
||||
|
||||
async def test_regenerating_a_library_page_uses_its_template(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Restart the NAS", template_id="runbook")
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "gone"}, headers=headers)
|
||||
|
||||
res = await client.post(f"/api/v1/documents/{doc['id']}/regenerate", headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
assert "gone" not in res.json()["body"]
|
||||
assert "# Restart the NAS" in res.json()["body"]
|
||||
assert res.json()["template_id"] == "runbook"
|
||||
|
||||
|
||||
async def test_regenerating_a_folder_is_rejected(client: AsyncClient, headers: dict):
|
||||
folder = await _create(client, headers, title="Runbooks", kind="folder")
|
||||
res = await client.post(f"/api/v1/documents/{folder['id']}/regenerate", headers=headers)
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
async def test_regenerating_an_unknown_document_is_404(client: AsyncClient, headers: dict):
|
||||
res = await client.post(f"/api/v1/documents/{uuid.uuid4()}/regenerate", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
# ── delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_deleting_a_folder_takes_its_subtree(client: AsyncClient, headers: dict):
|
||||
outer = await _create(client, headers, title="Outer", kind="folder")
|
||||
inner = await _create(client, headers, title="Inner", kind="folder", parent_id=outer["id"])
|
||||
leaf = await _create(client, headers, title="Leaf", parent_id=inner["id"])
|
||||
|
||||
assert (await client.delete(f"/api/v1/documents/{outer['id']}", headers=headers)).status_code == 204
|
||||
for doc_id in (outer["id"], inner["id"], leaf["id"]):
|
||||
assert (await client.get(f"/api/v1/documents/{doc_id}", headers=headers)).status_code == 404
|
||||
|
||||
|
||||
async def test_deleting_a_missing_document_is_404(client: AsyncClient, headers: dict):
|
||||
assert (await client.delete(f"/api/v1/documents/{uuid.uuid4()}", headers=headers)).status_code == 404
|
||||
|
||||
|
||||
async def test_a_deleted_document_leaves_the_search_index(client: AsyncClient, headers: dict):
|
||||
doc = await _create(client, headers, title="Page", body="unmistakable")
|
||||
await client.delete(f"/api/v1/documents/{doc['id']}", headers=headers)
|
||||
hits = (await client.get("/api/v1/documents/search?q=unmistakable", headers=headers)).json()["hits"]
|
||||
assert hits == []
|
||||
|
||||
|
||||
# ── the document outlives what it describes ─────────────────────────────────
|
||||
|
||||
|
||||
async def test_deleting_a_device_orphans_its_document_but_keeps_the_body(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers, notes="Holds the backups.")
|
||||
doc = await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
|
||||
assert (await client.delete(f"/api/v1/scan/pending/{device['id']}", headers=headers)).status_code == 200
|
||||
|
||||
after = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
|
||||
assert after["device_id"] is None
|
||||
assert after["title"] == "nas-01"
|
||||
assert "Holds the backups." in after["body"]
|
||||
|
||||
|
||||
async def test_deleting_a_node_orphans_the_document_describing_it(client: AsyncClient, headers: dict):
|
||||
design_id = await _design(client, headers)
|
||||
zone = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "groupRect", "label": "Garage", "design_id": design_id, "pos_x": 0, "pos_y": 0},
|
||||
headers=headers,
|
||||
)
|
||||
doc = await _create(client, headers, title="Garage", kind="node", node_id=zone.json()["id"])
|
||||
|
||||
await client.delete(f"/api/v1/nodes/{zone.json()['id']}", headers=headers)
|
||||
|
||||
after = (await client.get(f"/api/v1/documents/{doc['id']}", headers=headers)).json()
|
||||
assert after["node_id"] is None
|
||||
assert after["title"] == "Garage"
|
||||
|
||||
|
||||
# ── search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_search_names_its_engine_and_returns_hits(client: AsyncClient, headers: dict):
|
||||
await _create(client, headers, title="VLAN plan", body="Guest traffic is isolated.")
|
||||
res = await client.get("/api/v1/documents/search?q=isolated", headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
payload = res.json()
|
||||
assert payload["engine"] in ("fts5", "like")
|
||||
assert [h["title"] for h in payload["hits"]] == ["VLAN plan"]
|
||||
|
||||
|
||||
async def test_search_carries_the_device_link_so_a_hit_can_be_opened(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
await _create(client, headers, title="nas-01", kind="device", device_id=device["id"])
|
||||
hits = (await client.get("/api/v1/documents/search?q=nas", headers=headers)).json()["hits"]
|
||||
assert hits[0]["device_id"] == device["id"]
|
||||
|
||||
|
||||
async def test_search_rejects_a_silly_limit(client: AsyncClient, headers: dict):
|
||||
assert (await client.get("/api/v1/documents/search?q=x&limit=0", headers=headers)).status_code == 422
|
||||
|
||||
|
||||
# ── scaffold and coverage ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_scaffold_documents_every_device_that_has_none(client: AsyncClient, headers: dict):
|
||||
await _device(client, headers, label="a", ip="10.0.0.1")
|
||||
await _device(client, headers, label="b", ip="10.0.0.2")
|
||||
res = await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
assert len(res.json()["created"]) == 2
|
||||
|
||||
|
||||
async def test_scaffold_is_repeatable_and_skips_what_exists(client: AsyncClient, headers: dict):
|
||||
await _device(client, headers, label="a", ip="10.0.0.1")
|
||||
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
|
||||
again = (await client.post("/api/v1/documents/scaffold", json={}, headers=headers)).json()
|
||||
assert again["created"] == []
|
||||
assert again["skipped"] == 1
|
||||
|
||||
|
||||
async def test_scaffold_can_be_limited_to_devices_that_have_notes(client: AsyncClient, headers: dict):
|
||||
await _device(client, headers, label="a", ip="10.0.0.1", notes="worth keeping")
|
||||
await _device(client, headers, label="b", ip="10.0.0.2")
|
||||
res = (
|
||||
await client.post("/api/v1/documents/scaffold", json={"only_with_notes": True}, headers=headers)
|
||||
).json()
|
||||
assert [d["title"] for d in res["created"]] == ["a"]
|
||||
assert res["skipped"] == 1
|
||||
|
||||
|
||||
async def test_scaffold_can_be_limited_to_named_devices(client: AsyncClient, headers: dict):
|
||||
first = await _device(client, headers, label="a", ip="10.0.0.1")
|
||||
await _device(client, headers, label="b", ip="10.0.0.2")
|
||||
res = (
|
||||
await client.post("/api/v1/documents/scaffold", json={"device_ids": [first["id"]]}, headers=headers)
|
||||
).json()
|
||||
assert [d["title"] for d in res["created"]] == ["a"]
|
||||
|
||||
|
||||
async def test_a_migrated_document_records_why_it_exists(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers, notes="worth keeping")
|
||||
created = (
|
||||
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
|
||||
).json()["created"][0]
|
||||
revisions = (await client.get(f"/api/v1/documents/{created['id']}/revisions", headers=headers)).json()
|
||||
assert revisions[0]["reason"] == "migrate"
|
||||
assert device["id"]
|
||||
|
||||
|
||||
async def test_coverage_counts_what_is_missing_and_unmigrated(client: AsyncClient, headers: dict):
|
||||
await _device(client, headers, label="a", ip="10.0.0.1", notes="notes here")
|
||||
await _device(client, headers, label="b", ip="10.0.0.2")
|
||||
|
||||
before = (await client.get("/api/v1/documents/coverage", headers=headers)).json()
|
||||
assert before["devices"] == 2
|
||||
assert before["documented"] == 0
|
||||
assert before["missing"] == 2
|
||||
assert before["notes_unmigrated"] == 1
|
||||
|
||||
await client.post("/api/v1/documents/scaffold", json={}, headers=headers)
|
||||
after = (await client.get("/api/v1/documents/coverage", headers=headers)).json()
|
||||
assert after["documented"] == 2
|
||||
assert after["missing"] == 0
|
||||
assert after["notes_unmigrated"] == 0
|
||||
# Generated and not yet touched.
|
||||
assert after["header_only"] == 2
|
||||
|
||||
|
||||
async def test_coverage_reports_a_document_that_has_drifted(client: AsyncClient, headers: dict):
|
||||
device = await _device(client, headers)
|
||||
await _create(client, headers, title="nas", kind="device", device_id=device["id"])
|
||||
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["drifted"] == 0
|
||||
|
||||
await client.patch(f"/api/v1/scan/pending/{device['id']}", json={"ip": "192.168.1.99"}, headers=headers)
|
||||
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["drifted"] == 1
|
||||
|
||||
|
||||
async def test_coverage_stops_calling_a_document_header_only_once_it_is_edited(
|
||||
client: AsyncClient, headers: dict
|
||||
):
|
||||
device = await _device(client, headers)
|
||||
doc = await _create(client, headers, title="nas", kind="device", device_id=device["id"])
|
||||
await client.patch(f"/api/v1/documents/{doc['id']}", json={"body": "written by hand"}, headers=headers)
|
||||
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["header_only"] == 0
|
||||
|
||||
|
||||
async def test_coverage_counts_library_pages_separately(client: AsyncClient, headers: dict):
|
||||
await _create(client, headers, title="VLAN plan")
|
||||
await _create(client, headers, title="Runbooks", kind="folder")
|
||||
assert (await client.get("/api/v1/documents/coverage", headers=headers)).json()["library_pages"] == 2
|
||||
@@ -0,0 +1,158 @@
|
||||
"""The documents DDL `create_all` does not apply.
|
||||
|
||||
Column additions to an existing table (which `create_all` never makes), plus a
|
||||
virtual table and three partial indexes (which it cannot express at all), all
|
||||
applied through `_try_migrate` at boot. Every one has to survive a second boot
|
||||
untouched, and the partial uniques are what stop two documents claiming the same
|
||||
device behind the route's own check.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db.database import DOCUMENT_DDL, Base, _try_migrate
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _boot(tmp_path, times: int = 1):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
||||
for _ in range(times):
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
for label, sql in DOCUMENT_DDL:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
return engine
|
||||
|
||||
|
||||
async def _names(engine, kind: str) -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.exec_driver_sql(f"SELECT name FROM sqlite_master WHERE type = '{kind}'")
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
|
||||
async def test_the_tables_and_the_search_index_are_created(tmp_path):
|
||||
engine = await _boot(tmp_path)
|
||||
tables = await _names(engine, "table")
|
||||
assert {"documents", "document_revisions", "documents_fts"} <= tables
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_the_partial_uniques_are_created(tmp_path):
|
||||
engine = await _boot(tmp_path)
|
||||
indexes = await _names(engine, "index")
|
||||
assert {"ux_documents_device", "ux_documents_node", "ux_documents_design"} <= indexes
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_second_boot_changes_nothing(tmp_path):
|
||||
engine = await _boot(tmp_path, times=3)
|
||||
tables = await _names(engine, "table")
|
||||
assert {"documents", "documents_fts"} <= tables
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_two_documents_cannot_claim_the_same_device(tmp_path):
|
||||
engine = await _boot(tmp_path)
|
||||
async with engine.begin() as conn:
|
||||
insert = (
|
||||
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
|
||||
"frontmatter, tags, starred, created_at, updated_at) "
|
||||
"VALUES (?, 'device', 'nas', ?, '', 'dev-1', 0, '{}', '[]', 0, "
|
||||
"'2026-09-05', '2026-09-05')"
|
||||
)
|
||||
await conn.exec_driver_sql(insert, ("a", "nas"))
|
||||
with pytest.raises(IntegrityError):
|
||||
await conn.exec_driver_sql(insert, ("b", "nas-2"))
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_many_documents_may_have_no_device(tmp_path):
|
||||
engine = await _boot(tmp_path)
|
||||
async with engine.begin() as conn:
|
||||
insert = (
|
||||
"INSERT INTO documents (id, kind, title, slug, body, device_id, sort_order, "
|
||||
"frontmatter, tags, starred, created_at, updated_at) "
|
||||
"VALUES (?, 'page', 'p', ?, '', NULL, 0, '{}', '[]', 0, "
|
||||
"'2026-09-05', '2026-09-05')"
|
||||
)
|
||||
await conn.exec_driver_sql(insert, ("a", "p1"))
|
||||
await conn.exec_driver_sql(insert, ("b", "p2"))
|
||||
count = (await conn.exec_driver_sql("SELECT COUNT(*) FROM documents")).scalar_one()
|
||||
assert count == 2
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
# ── columns added to a table that already exists ─────────────────────────────
|
||||
|
||||
# `documents` as an earlier build of the feature created it: no `edited_at`.
|
||||
_DOCUMENTS_WITHOUT_EDITED_AT = (
|
||||
"CREATE TABLE documents ("
|
||||
"id VARCHAR NOT NULL PRIMARY KEY, kind VARCHAR NOT NULL, title VARCHAR NOT NULL, "
|
||||
"slug VARCHAR NOT NULL, icon VARCHAR, parent_id VARCHAR, sort_order INTEGER, "
|
||||
"device_id VARCHAR, node_id VARCHAR, design_id VARCHAR, body TEXT NOT NULL, "
|
||||
"frontmatter JSON, tags JSON, starred BOOLEAN, template_id VARCHAR, "
|
||||
"facts_snapshot JSON, facts_synced_at DATETIME, reviewed_at DATETIME, "
|
||||
"created_at DATETIME, updated_at DATETIME)"
|
||||
)
|
||||
|
||||
|
||||
async def _columns(engine, table: str) -> list[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql(f"PRAGMA table_info({table})")).fetchall()
|
||||
return [row[1] for row in rows]
|
||||
|
||||
|
||||
async def test_an_existing_table_gains_the_columns_create_all_would_not_add(tmp_path):
|
||||
"""The regression: `create_all` makes a missing table, never a missing column.
|
||||
|
||||
A database that met an earlier shape of `documents` kept it, and every query
|
||||
naming `edited_at` failed with ``no such column: documents.edited_at`` —
|
||||
including the one behind opening the Documentation section.
|
||||
"""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DOCUMENTS_WITHOUT_EDITED_AT)
|
||||
assert "edited_at" not in await _columns(engine, "documents")
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all) # sees the table, leaves it be
|
||||
for label, sql in DOCUMENT_DDL:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
|
||||
assert "edited_at" in await _columns(engine, "documents")
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_the_old_rows_survive_the_column_being_added(tmp_path):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'test.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DOCUMENTS_WITHOUT_EDITED_AT)
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO documents (id, kind, title, slug, body) "
|
||||
"VALUES ('d1', 'page', 'Rebuild plan', 'rebuild-plan', '# Rebuild plan')"
|
||||
)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
for label, sql in DOCUMENT_DDL:
|
||||
await _try_migrate(conn, sql, label=label)
|
||||
|
||||
async with engine.begin() as conn:
|
||||
row = (
|
||||
await conn.exec_driver_sql("SELECT title, body, edited_at FROM documents WHERE id = 'd1'")
|
||||
).fetchone()
|
||||
assert row == ("Rebuild plan", "# Rebuild plan", None)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_a_fresh_database_has_every_column(tmp_path):
|
||||
engine = await _boot(tmp_path, times=2)
|
||||
columns = await _columns(engine, "documents")
|
||||
assert "edited_at" in columns
|
||||
# The re-stated ALTERs are idempotent: no column is added twice.
|
||||
assert len(columns) == len(set(columns))
|
||||
await engine.dispose()
|
||||
@@ -330,3 +330,154 @@ async def test_create_edge_child_node_abs_y_resolved_through_parent(client: Asyn
|
||||
data = res.json()
|
||||
assert data["source_handle"] == "bottom"
|
||||
assert data["target_handle"] == "top-t"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection-point guards (#435)
|
||||
# ---------------------------------------------------------------------------
|
||||
# An edge whose handle ID resolves to no element is dropped by React Flow: it
|
||||
# persists in the DB and is simply invisible. Handles became settable over the
|
||||
# MCP, so the endpoints now check them.
|
||||
|
||||
|
||||
async def test_create_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "right-3", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert "right-3" in res.json()["detail"]
|
||||
assert (await client.get("/api/v1/edges", headers=headers)).json() == []
|
||||
|
||||
|
||||
async def test_create_edge_rejects_a_side_the_node_opted_out_of(client: AsyncClient, headers: dict):
|
||||
# left/right default to zero connection points, so even slot 0 is absent.
|
||||
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/edges", json={"source": src, "target": tgt, "source_handle": "left"}, headers=headers
|
||||
)
|
||||
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
async def test_create_edge_accepts_a_handle_the_node_opted_into(client: AsyncClient, headers: dict):
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "proxmox", "label": "pve1", "right_handles": 1}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "proxmox", "label": "pve2", "left_handles": 1}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "right", "target_handle": "left"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert res.json()["source_handle"] == "right"
|
||||
|
||||
|
||||
async def test_create_edge_accepts_the_target_form_of_a_handle(client: AsyncClient, headers: dict):
|
||||
# '<side>-t' is the invisible target twin the canvas renders beside slot 0;
|
||||
# the auto-assignment stores exactly that, so it must validate.
|
||||
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1"}, headers=headers)).json()["id"]
|
||||
tgt = (
|
||||
await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1", "top_handles": 2}, headers=headers)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "bottom", "target_handle": "top-2-t"},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert res.json()["target_handle"] == "top-2-t"
|
||||
|
||||
|
||||
async def test_create_edge_leaves_a_foreign_handle_namespace_alone(client: AsyncClient, headers: dict, two_nodes):
|
||||
# 'cluster-right' is not a per-side handle; no count here describes it, so
|
||||
# the guard must not judge it.
|
||||
src, tgt = two_nodes
|
||||
res = await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "type": "cluster", "source_handle": "cluster-right"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
|
||||
|
||||
async def test_update_edge_rejects_a_handle_the_node_does_not_have(client: AsyncClient, headers: dict, two_nodes):
|
||||
src, tgt = two_nodes
|
||||
edge_id = (
|
||||
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-9"}, headers=headers)
|
||||
|
||||
assert res.status_code == 422
|
||||
edge = (await client.get("/api/v1/edges", headers=headers)).json()[0]
|
||||
assert edge["source_handle"] == "bottom"
|
||||
|
||||
|
||||
async def test_update_edge_accepts_a_handle_the_node_has(client: AsyncClient, headers: dict):
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"source_handle": "bottom-4"}, headers=headers)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["source_handle"] == "bottom-4"
|
||||
|
||||
|
||||
async def test_auto_assignment_avoids_a_side_with_no_connection_point(client: AsyncClient, headers: dict):
|
||||
# The caller supplied no handle, so the server corrects its own pick rather
|
||||
# than raising: bottom is gone, and the node's remaining side is used.
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "sw1", "bottom_handles": 0, "top_handles": 0, "right_handles": 2},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
||||
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert res.json()["source_handle"] == "right"
|
||||
assert res.json()["target_handle"] == "top-t"
|
||||
|
||||
|
||||
async def test_auto_assignment_keeps_its_pick_when_the_node_has_nothing(client: AsyncClient, headers: dict):
|
||||
# Every side is empty: there is no better handle to reach for, and refusing
|
||||
# the edge would lose more than it saves.
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "sw1", "top_handles": 0, "bottom_handles": 0},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "N1"}, headers=headers)).json()["id"]
|
||||
|
||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt}, headers=headers)
|
||||
|
||||
assert res.status_code == 201
|
||||
assert res.json()["source_handle"] == "bottom"
|
||||
|
||||
@@ -124,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"}])
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for the per-side connection-point vocabulary.
|
||||
|
||||
Mirrors frontend/src/utils/handleUtils.ts — the two must agree, or the server
|
||||
stores handles the canvas cannot draw.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.schemas.utils import (
|
||||
MAX_HANDLES,
|
||||
clamp_handles,
|
||||
handle_count_field,
|
||||
handle_id,
|
||||
parse_side_handle,
|
||||
removed_handle_ids,
|
||||
side_default,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("side", "expected"), [("top", 1), ("bottom", 1), ("left", 0), ("right", 0)])
|
||||
def test_side_default(side: str, expected: int) -> None:
|
||||
assert side_default(side) == expected
|
||||
|
||||
|
||||
def test_handle_count_field() -> None:
|
||||
assert handle_count_field("left") == "left_handles"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "expected"), [(0, 0), (3, 3), (64, 64), (65, 64), (5000, MAX_HANDLES), (-1, 0)])
|
||||
def test_clamp_handles_bounds(raw: int, expected: int) -> None:
|
||||
assert clamp_handles("bottom", raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", [None, "4", float("nan"), float("inf"), True])
|
||||
def test_clamp_handles_falls_back_to_the_side_default(raw: object) -> None:
|
||||
# A missing or corrupt count still yields the historical number, per side.
|
||||
assert clamp_handles("bottom", raw) == 1
|
||||
assert clamp_handles("left", raw) == 0
|
||||
|
||||
|
||||
def test_clamp_handles_truncates_a_fraction() -> None:
|
||||
assert clamp_handles("bottom", 3.9) == 3
|
||||
|
||||
|
||||
def test_handle_id_keeps_the_bare_side_name_for_slot_zero() -> None:
|
||||
# Slot 0 stays 'bottom' so edges saved before the multi-handle expansion
|
||||
# keep resolving.
|
||||
assert handle_id("bottom", 0) == "bottom"
|
||||
assert handle_id("bottom", 1) == "bottom-2"
|
||||
assert handle_id("left", 3) == "left-4"
|
||||
|
||||
|
||||
def test_removed_handle_ids() -> None:
|
||||
assert removed_handle_ids("bottom", 4, 2) == {"bottom-3", "bottom-4"}
|
||||
assert removed_handle_ids("bottom", 2, 2) == set()
|
||||
assert removed_handle_ids("bottom", 1, 4) == set()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("handle", "expected"),
|
||||
[
|
||||
("top", ("top", 0)),
|
||||
("bottom-2", ("bottom", 1)),
|
||||
("left-4", ("left", 3)),
|
||||
# The invisible target twin resolves to the same slot as its source.
|
||||
("right-t", ("right", 0)),
|
||||
("bottom-3-t", ("bottom", 2)),
|
||||
],
|
||||
)
|
||||
def test_parse_side_handle(handle: str, expected: tuple[str, int]) -> None:
|
||||
assert parse_side_handle(handle) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handle", ["cluster-right", "", "middle", "top-left", "bottom-x"])
|
||||
def test_parse_side_handle_ignores_a_foreign_namespace(handle: str) -> None:
|
||||
# Not a per-side handle: whoever owns it interprets it, not the side counts.
|
||||
assert parse_side_handle(handle) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("handle", ["bottom-0", "bottom-1"])
|
||||
def test_parse_side_handle_reports_an_impossible_slot(handle: str) -> None:
|
||||
# The numbered form is 2-based, so these are IDs the canvas never emits;
|
||||
# slot -1 means no count can satisfy them.
|
||||
assert parse_side_handle(handle) == ("bottom", -1)
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Tests for the HTTP probe used by deep-scan service identification."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.services.http_probe import (
|
||||
_MAX_BODY_BYTES,
|
||||
_extract_title,
|
||||
probe_open_ports,
|
||||
probe_port,
|
||||
@@ -15,6 +16,39 @@ def _response(text: str = "", headers: dict | None = None, status: int = 200) ->
|
||||
return httpx.Response(status_code=status, text=text, headers=headers or {})
|
||||
|
||||
|
||||
class _TransportClient:
|
||||
"""
|
||||
Patch target for httpx.AsyncClient that routes through a MockTransport.
|
||||
|
||||
Real client, fake network: the probe exercises the genuine httpx request
|
||||
path (including .stream() and aiter_bytes()) instead of a mock that would
|
||||
happily accept any call shape. `requests` records what was actually sent.
|
||||
"""
|
||||
|
||||
def __init__(self, handler):
|
||||
self._handler = handler
|
||||
# Bound before patching, so building the real client here does not
|
||||
# recurse back into this stand-in.
|
||||
self._real = httpx.AsyncClient
|
||||
self.kwargs: list[dict] = []
|
||||
self.requests: list[httpx.Request] = []
|
||||
|
||||
def _record(self, request: httpx.Request) -> httpx.Response:
|
||||
self.requests.append(request)
|
||||
return self._handler(request)
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
self.kwargs.append(dict(kwargs))
|
||||
kwargs.pop("verify", None)
|
||||
return self._real(transport=httpx.MockTransport(self._record), **kwargs)
|
||||
|
||||
|
||||
def _patch_client(handler) -> tuple:
|
||||
"""Return (context manager, factory) patching http_probe's AsyncClient."""
|
||||
factory = _TransportClient(handler)
|
||||
return patch("app.services.http_probe.httpx.AsyncClient", factory), factory
|
||||
|
||||
|
||||
# ── _extract_title ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_extract_title_basic():
|
||||
@@ -37,15 +71,18 @@ def test_extract_title_case_insensitive():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_reads_title():
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
||||
ctx, _ = _patch_client(lambda req: _response("<title>Jellyfin</title>"))
|
||||
with ctx:
|
||||
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)):
|
||||
ctx, _ = _patch_client(
|
||||
lambda req: _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
||||
)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 3000)
|
||||
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
|
||||
|
||||
@@ -53,30 +90,33 @@ async def test_probe_port_reads_headers():
|
||||
@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"):
|
||||
def handler(request):
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("tls fail")
|
||||
return _response("<title>HTTP App</title>")
|
||||
|
||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
||||
ctx, factory = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result["title"] == "HTTP App"
|
||||
assert calls["n"] == 2 # tried https then http
|
||||
assert len(factory.requests) == 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(""))):
|
||||
ctx, _ = _patch_client(lambda req: _response(""))
|
||||
with ctx:
|
||||
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"))):
|
||||
def handler(request):
|
||||
raise httpx.TimeoutException("slow")
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8080)
|
||||
assert result is None
|
||||
|
||||
@@ -84,33 +124,126 @@ async def test_probe_port_timeout_returns_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):
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>nope</title>"))
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 22)
|
||||
assert result is None
|
||||
get.assert_not_called()
|
||||
assert factory.requests == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [515, 9100, 9101, 9107])
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_port_never_touches_a_raw_printing_port(port):
|
||||
"""Issue #404: a printer prints whatever bytes reach 9100, GET line included."""
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>nope</title>"))
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", port)
|
||||
assert result is None
|
||||
assert factory.requests == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_open_ports_leaves_a_printer_port_alone():
|
||||
"""The batch path must skip it too, without dropping the port from the result."""
|
||||
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9100, "protocol": "tcp"}]
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>Jellyfin</title>"))
|
||||
with ctx:
|
||||
result = await probe_open_ports("10.0.0.5", ports)
|
||||
|
||||
by_port = {p["port"]: p for p in result}
|
||||
assert by_port[9100]["http_signals"] is None
|
||||
assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
|
||||
assert all(":9100" not in str(r.url) for r in factory.requests)
|
||||
|
||||
|
||||
@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>"))
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>X</title>"))
|
||||
with ctx:
|
||||
await probe_port("10.0.0.5", 8443, verify_tls=True)
|
||||
assert client_cls.call_args.kwargs["verify"] is True
|
||||
assert factory.kwargs[0]["verify"] is True
|
||||
|
||||
|
||||
# ── endless bodies (issue #375) ─────────────────────────────────────────────
|
||||
|
||||
_CHUNK_SIZE = 16 * 1024
|
||||
|
||||
|
||||
def _endless_body(counter: dict, head: bytes = b""):
|
||||
"""
|
||||
A body that never ends and declares no Content-Length. Bounded at 512
|
||||
chunks so a regression fails the test instead of hanging the suite.
|
||||
"""
|
||||
|
||||
async def gen():
|
||||
if head:
|
||||
counter["bytes"] += len(head)
|
||||
yield head
|
||||
for _ in range(512):
|
||||
counter["bytes"] += _CHUNK_SIZE
|
||||
yield b"\0" * _CHUNK_SIZE
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_caps_the_download_of_an_endless_body():
|
||||
# _MAX_BODY_BYTES caps the download, not just the <title> scan: the body is
|
||||
# streamed and the connection dropped once we hold enough. Allow one chunk
|
||||
# of overshoot — the read stops on a chunk boundary.
|
||||
counter = {"bytes": 0}
|
||||
|
||||
def handler(request):
|
||||
# Plain HTTP only, like the reproducer, so the counter covers one probe.
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("no tls")
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
content=_endless_body(counter),
|
||||
)
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8095)
|
||||
assert result is None # null bytes carry no title and no signal header
|
||||
assert counter["bytes"] <= _MAX_BODY_BYTES + _CHUNK_SIZE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_still_reads_a_title_from_an_endless_body():
|
||||
# Stopping early must not cost us the signal: a <title> in the first chunk
|
||||
# is still found even though the rest of the stream is abandoned.
|
||||
counter = {"bytes": 0}
|
||||
|
||||
def handler(request):
|
||||
if str(request.url).startswith("https"):
|
||||
raise httpx.ConnectError("no tls")
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"Content-Type": "text/html"},
|
||||
content=_endless_body(counter, head=b"<html><title>Jellyfin</title>"),
|
||||
)
|
||||
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_port("10.0.0.5", 8096)
|
||||
assert result["title"] == "Jellyfin"
|
||||
assert counter["bytes"] <= _MAX_BODY_BYTES + _CHUNK_SIZE
|
||||
|
||||
|
||||
# ── 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:
|
||||
def handler(request):
|
||||
if ":8096" in str(request.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):
|
||||
ctx, _ = _patch_client(handler)
|
||||
with ctx:
|
||||
result = await probe_open_ports("10.0.0.5", ports)
|
||||
|
||||
by_port = {p["port"]: p for p in result}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Unit tests for the in-process canvas-import job registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import import_jobs
|
||||
from app.services.import_jobs import (
|
||||
create_job,
|
||||
fail_job,
|
||||
finish_job,
|
||||
get_job,
|
||||
reset_jobs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean() -> None:
|
||||
reset_jobs()
|
||||
|
||||
|
||||
def test_create_job_starts_running_with_unique_id() -> None:
|
||||
a = create_job()
|
||||
b = create_job()
|
||||
assert a.id != b.id
|
||||
assert a.status == "running"
|
||||
assert a.result is None
|
||||
assert a.error is None
|
||||
|
||||
|
||||
def test_get_job_returns_the_registered_job() -> None:
|
||||
job = create_job()
|
||||
assert get_job(job.id) is job
|
||||
|
||||
|
||||
def test_get_unknown_job_returns_none() -> None:
|
||||
assert get_job("nope") is None
|
||||
|
||||
|
||||
def test_finish_job_records_the_payload() -> None:
|
||||
job = create_job()
|
||||
finish_job(job.id, {"device_count": 3})
|
||||
stored = get_job(job.id)
|
||||
assert stored is not None
|
||||
assert stored.status == "done"
|
||||
assert stored.result == {"device_count": 3}
|
||||
assert stored.finished_at is not None
|
||||
|
||||
|
||||
def test_fail_job_records_message_and_status() -> None:
|
||||
job = create_job()
|
||||
fail_job(job.id, "broker unreachable", 502)
|
||||
stored = get_job(job.id)
|
||||
assert stored is not None
|
||||
assert stored.status == "error"
|
||||
assert stored.error == "broker unreachable"
|
||||
assert stored.error_status == 502
|
||||
|
||||
|
||||
def test_finish_and_fail_are_noops_for_unknown_ids() -> None:
|
||||
finish_job("gone", {"device_count": 0})
|
||||
fail_job("gone", "boom", 500)
|
||||
assert get_job("gone") is None
|
||||
|
||||
|
||||
def test_finished_jobs_expire_after_the_ttl(monkeypatch) -> None:
|
||||
clock = {"now": 0.0}
|
||||
monkeypatch.setattr(import_jobs.time, "monotonic", lambda: clock["now"])
|
||||
|
||||
job = create_job()
|
||||
finish_job(job.id, {"device_count": 1})
|
||||
|
||||
clock["now"] = import_jobs.JOB_TTL_SECONDS + 1
|
||||
assert get_job(job.id) is None
|
||||
|
||||
|
||||
def test_running_jobs_never_expire(monkeypatch) -> None:
|
||||
"""A slow mesh must not have its job purged out from under it."""
|
||||
clock = {"now": 0.0}
|
||||
monkeypatch.setattr(import_jobs.time, "monotonic", lambda: clock["now"])
|
||||
|
||||
job = create_job()
|
||||
clock["now"] = import_jobs.JOB_TTL_SECONDS * 10
|
||||
assert get_job(job.id) is not None
|
||||
|
||||
|
||||
def test_reset_jobs_clears_everything() -> None:
|
||||
job = create_job()
|
||||
reset_jobs()
|
||||
assert get_job(job.id) is None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.mqtt_common import (
|
||||
_RESPONSE_TIMEOUT,
|
||||
_build_tls_context,
|
||||
_sanitize_mqtt_error,
|
||||
request_response,
|
||||
@@ -181,3 +184,100 @@ async def test_test_connection_failure() -> None:
|
||||
mock_aiomqtt.MqttError = Exception
|
||||
with pytest.raises(ConnectionError):
|
||||
await _test_connection("bad", 1883)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response timeout — configurable via MQTT_RESPONSE_TIMEOUT (issue #380)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SilentClient:
|
||||
"""An MQTT client that connects but never delivers the response message."""
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
pass
|
||||
|
||||
async def subscribe(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
async def publish(self, *_a, **_kw) -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def messages(self):
|
||||
async def _never():
|
||||
await asyncio.Event().wait()
|
||||
yield # pragma: no cover
|
||||
|
||||
return _never()
|
||||
|
||||
|
||||
class _NeverMqttError(Exception):
|
||||
"""Stand-in for aiomqtt.MqttError that no test path actually raises."""
|
||||
|
||||
|
||||
async def _round_trip(**kwargs):
|
||||
return await request_response(
|
||||
mqtt_host="host",
|
||||
mqtt_port=1883,
|
||||
request_topic="req",
|
||||
response_topic="res",
|
||||
request_payload={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_uses_settings_timeout(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 0.01)
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await _round_trip()
|
||||
|
||||
msg = str(ei.value)
|
||||
assert "0.01s" in msg
|
||||
assert "MQTT_RESPONSE_TIMEOUT" in msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_explicit_timeout_overrides_settings(monkeypatch) -> None:
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 999)
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
|
||||
with pytest.raises(TimeoutError) as ei:
|
||||
await _round_trip(response_timeout=0.01)
|
||||
|
||||
assert "0.01s" in str(ei.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_response_non_positive_setting_falls_back(monkeypatch) -> None:
|
||||
"""A misconfigured 0 must not mean 'give up immediately'."""
|
||||
monkeypatch.setattr(settings, "mqtt_response_timeout", 0)
|
||||
captured: dict[str, float] = {}
|
||||
|
||||
async def _fake_wait_for(awaitable, timeout):
|
||||
captured["timeout"] = timeout
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
||||
mock_aiomqtt.Client.return_value = _SilentClient()
|
||||
mock_aiomqtt.MqttError = _NeverMqttError
|
||||
with (
|
||||
patch("app.services.mqtt_common.asyncio.wait_for", _fake_wait_for),
|
||||
pytest.raises(TimeoutError),
|
||||
):
|
||||
await _round_trip()
|
||||
|
||||
assert captured["timeout"] == _RESPONSE_TIMEOUT
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
|
||||
"""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, Node
|
||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||
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"):
|
||||
@@ -14,61 +20,80 @@ async def _design(db, name="d1"):
|
||||
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_ieee_same_design(db_session):
|
||||
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,
|
||||
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
|
||||
pos_x=100, pos_y=200,
|
||||
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,
|
||||
ieee_address="0xAAA", ip="10.0.0.5",
|
||||
properties=[{"key": "LQI", "value": "88", "visible": False}],
|
||||
)
|
||||
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_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
|
||||
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
|
||||
assert survivor.ip == "10.0.0.5" # missing field filled from dup
|
||||
keys = {p["key"] for p in survivor.properties}
|
||||
assert keys == {"IEEE", "LQI"} # properties merged
|
||||
# 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_ieee_across_designs(db_session):
|
||||
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, ieee_address="0xBBB"))
|
||||
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_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 0
|
||||
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
|
||||
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)
|
||||
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
||||
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()
|
||||
|
||||
@@ -80,7 +105,7 @@ async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
])
|
||||
await db_session.flush()
|
||||
|
||||
removed = await dedupe_nodes_by_ieee(db_session)
|
||||
removed = await dedupe_nodes_by_device(db_session)
|
||||
assert removed == 1
|
||||
|
||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
||||
@@ -93,25 +118,51 @@ async def test_repoints_edges_and_drops_dupes(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_repoints_child_parent(db_session):
|
||||
d = await _design(db_session)
|
||||
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
||||
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_ieee(db_session)
|
||||
await dedupe_nodes_by_device(db_session)
|
||||
await db_session.refresh(child)
|
||||
assert child.parent_id == keep.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canonical_nested_under_its_own_duplicate_is_detached(db_session):
|
||||
"""#370 — re-pointing the canonical node made it its own parent.
|
||||
|
||||
The canonical node had been nested under one of its duplicates, so the
|
||||
blanket re-point wrote `parent_id = id` and froze it on the canvas.
|
||||
"""
|
||||
d = await _design(db_session)
|
||||
device = await _device(db_session, ieee="0xFFF")
|
||||
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()
|
||||
# The oldest node is the one that survives, and it points at the newer twin.
|
||||
keep.parent_id = dup.id
|
||||
await db_session.flush()
|
||||
|
||||
await dedupe_nodes_by_device(db_session)
|
||||
await db_session.refresh(keep)
|
||||
assert keep.parent_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotent_noop_when_unique(db_session):
|
||||
d = await _design(db_session)
|
||||
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
|
||||
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_ieee(db_session) == 0
|
||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
assert await dedupe_nodes_by_device(db_session) == 0
|
||||
|
||||
@@ -403,3 +403,268 @@ async def test_list_nodes_without_label_param_returns_all(client: AsyncClient, h
|
||||
res = await client.get("/api/v1/nodes", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert len(res.json()) == 3
|
||||
|
||||
|
||||
# ── self-parent guard (#370) ─────────────────────────────────────────────────
|
||||
|
||||
async def test_update_node_ignores_a_parent_id_pointing_at_itself(client: AsyncClient, headers: dict):
|
||||
create = await client.post(
|
||||
"/api/v1/nodes", json={"type": "lxc", "label": "pihole", "status": "unknown"}, headers=headers
|
||||
)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{node_id}", json={"parent_id": node_id, "label": "renamed"}, headers=headers
|
||||
)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["parent_id"] is None
|
||||
# The key is dropped, not the whole edit.
|
||||
assert res.json()["label"] == "renamed"
|
||||
|
||||
|
||||
async def test_update_node_self_parent_leaves_a_real_parent_alone(client: AsyncClient, headers: dict):
|
||||
host = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "proxmox", "label": "pve", "status": "unknown", "container_mode": True},
|
||||
headers=headers,
|
||||
)
|
||||
host_id = host.json()["id"]
|
||||
child = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "lxc", "label": "pihole", "status": "unknown", "parent_id": host_id},
|
||||
headers=headers,
|
||||
)
|
||||
child_id = child.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{child_id}", json={"parent_id": child_id}, headers=headers)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["parent_id"] == host_id
|
||||
|
||||
|
||||
# ── furniture descriptions ───────────────────────────────────────────────────
|
||||
|
||||
async def test_create_and_update_a_group_description(client: AsyncClient, headers: dict):
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "group", "label": "Cluster", "description": "The three Proxmox boxes."},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.status_code == 201
|
||||
assert create.json()["description"] == "The three Proxmox boxes."
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"description": "Now four."}, headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["description"] == "Now four."
|
||||
assert (await client.get(f"/api/v1/nodes/{node_id}", headers=headers)).json()["description"] == "Now four."
|
||||
|
||||
|
||||
async def test_update_a_zone_description_leaves_the_label_alone(client: AsyncClient, headers: dict):
|
||||
create = await client.post("/api/v1/nodes", json={"type": "groupRect", "label": "Garage"}, headers=headers)
|
||||
node_id = create.json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"description": "Behind the door."}, headers=headers)
|
||||
assert res.json()["label"] == "Garage"
|
||||
assert res.json()["description"] == "Behind the door."
|
||||
|
||||
|
||||
async def test_a_device_node_never_keeps_a_description(client: AsyncClient, headers: dict):
|
||||
# `description` is furniture-only: a node that draws a device writes what it
|
||||
# is for to the inventory row, as `notes`.
|
||||
create = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "nas", "label": "nas-01", "ip": "192.168.1.20", "description": "Not here."},
|
||||
headers=headers,
|
||||
)
|
||||
assert create.json()["description"] is None
|
||||
|
||||
res = await client.patch(
|
||||
f"/api/v1/nodes/{create.json()['id']}",
|
||||
json={"description": "Still not here.", "notes": "Backs up nightly."},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.json()["description"] is None
|
||||
assert res.json()["notes"] == "Backs up nightly."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection points (#435)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handle counts became writable over the MCP, which reaches these fields without
|
||||
# the canvas UI's own bounds and edge bookkeeping. Both now live server-side.
|
||||
|
||||
|
||||
async def test_handle_count_is_clamped_on_create(client: AsyncClient, headers: dict):
|
||||
# The renderer clamps to 0..64 anyway (handleUtils.clampHandles), so an
|
||||
# unclamped row would draw a different node than it stores.
|
||||
res = await client.post(
|
||||
"/api/v1/nodes",
|
||||
json={"type": "switch", "label": "sw1", "bottom_handles": 5000, "left_handles": -3},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 201
|
||||
assert res.json()["bottom_handles"] == 64
|
||||
assert res.json()["left_handles"] == 0
|
||||
|
||||
|
||||
async def test_handle_count_is_clamped_on_update(client: AsyncClient, headers: dict):
|
||||
node_id = (
|
||||
await client.post("/api/v1/nodes", json={"type": "switch", "label": "sw1"}, headers=headers)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"right_handles": 999}, headers=headers)
|
||||
|
||||
assert res.status_code == 200
|
||||
assert res.json()["right_handles"] == 64
|
||||
|
||||
|
||||
async def test_a_handle_count_of_zero_is_honoured(client: AsyncClient, headers: dict):
|
||||
# 0 is a real value, not a missing one: a side can have no connection point.
|
||||
node_id = (
|
||||
await client.post("/api/v1/nodes", json={"type": "switch", "label": "sw1"}, headers=headers)
|
||||
).json()["id"]
|
||||
|
||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"top_handles": 0}, headers=headers)
|
||||
|
||||
assert res.json()["top_handles"] == 0
|
||||
|
||||
|
||||
async def _edge_by_id(client: AsyncClient, headers: dict, edge_id: str) -> dict:
|
||||
edges = (await client.get("/api/v1/edges", headers=headers)).json()
|
||||
return next(e for e in edges if e["id"] == edge_id)
|
||||
|
||||
|
||||
async def test_shrinking_a_side_moves_its_edges_to_slot_zero(client: AsyncClient, headers: dict):
|
||||
# Regression: React Flow silently drops an edge whose handle no longer
|
||||
# exists, so the link vanishes from the canvas with no error.
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "bottom-4", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 2}, headers=headers)
|
||||
|
||||
edge = await _edge_by_id(client, headers, edge_id)
|
||||
assert edge["source_handle"] == "bottom"
|
||||
assert edge["target_handle"] == "top"
|
||||
|
||||
|
||||
async def test_shrinking_a_side_leaves_the_surviving_handles_alone(client: AsyncClient, headers: dict):
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "bottom-2", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 2}, headers=headers)
|
||||
|
||||
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-2"
|
||||
|
||||
|
||||
async def test_a_side_dropped_to_zero_falls_back_to_bottom(client: AsyncClient, headers: dict):
|
||||
# Slot 0 goes away with the side, so there is nothing on it to fall back to.
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "right_handles": 2}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "right-2", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{src}", json={"right_handles": 0}, headers=headers)
|
||||
|
||||
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom"
|
||||
|
||||
|
||||
async def test_the_target_end_is_remapped_too(client: AsyncClient, headers: dict):
|
||||
src = (await client.post("/api/v1/nodes", json={"type": "router", "label": "r1"}, headers=headers)).json()["id"]
|
||||
tgt = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "top_handles": 3}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "bottom", "target_handle": "top-3"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{tgt}", json={"top_handles": 1}, headers=headers)
|
||||
|
||||
edge = await _edge_by_id(client, headers, edge_id)
|
||||
assert edge["target_handle"] == "top"
|
||||
assert edge["source_handle"] == "bottom"
|
||||
|
||||
|
||||
async def test_growing_a_side_touches_no_edge(client: AsyncClient, headers: dict):
|
||||
src = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 2}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": src, "target": tgt, "source_handle": "bottom-2", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{src}", json={"bottom_handles": 8}, headers=headers)
|
||||
|
||||
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-2"
|
||||
|
||||
|
||||
async def test_another_nodes_edges_are_not_remapped(client: AsyncClient, headers: dict):
|
||||
# The shrink is scoped to the node being edited; an unrelated link that
|
||||
# happens to sit on the same handle ID must survive untouched.
|
||||
shrunk = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw1", "bottom_handles": 4}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
other = (
|
||||
await client.post(
|
||||
"/api/v1/nodes", json={"type": "switch", "label": "sw2", "bottom_handles": 4}, headers=headers
|
||||
)
|
||||
).json()["id"]
|
||||
tgt = (await client.post("/api/v1/nodes", json={"type": "nas", "label": "nas1"}, headers=headers)).json()["id"]
|
||||
edge_id = (
|
||||
await client.post(
|
||||
"/api/v1/edges",
|
||||
json={"source": other, "target": tgt, "source_handle": "bottom-4", "target_handle": "top"},
|
||||
headers=headers,
|
||||
)
|
||||
).json()["id"]
|
||||
|
||||
await client.patch(f"/api/v1/nodes/{shrunk}", json={"bottom_handles": 1}, headers=headers)
|
||||
|
||||
assert (await _edge_by_id(client, headers, edge_id))["source_handle"] == "bottom-4"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -17,7 +18,7 @@ from app.api.routes.proxmox 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, Node, PendingDevice, PendingDeviceLink
|
||||
from app.db.models import Design, Edge, InventoryDevice, InventoryDeviceLink, Node
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -39,14 +40,17 @@ def _host_node() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _guest_node(vmid: int, ip: str | None, status: str = "online", mac: str | None = None) -> dict:
|
||||
def _guest_node(
|
||||
vmid: int, ip: str | None, status: str = "online", mac: str | None = None,
|
||||
host: str = "pve1",
|
||||
) -> 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,
|
||||
"id": f"pve-{host}-{vmid}", "label": f"vm{vmid}", "type": "vm",
|
||||
"ieee_address": f"pve-{host}-{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",
|
||||
"parent_ieee": f"pve-node-{host}",
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +216,7 @@ async def test_persist_creates_pending(db_session) -> None:
|
||||
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(PendingDevice))).scalars().all()
|
||||
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")
|
||||
@@ -222,31 +226,39 @@ async def test_persist_creates_pending(db_session) -> None:
|
||||
@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",
|
||||
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
|
||||
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; identity + specs merged onto the existing one.
|
||||
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
|
||||
# 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 = nodes[0]
|
||||
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 []))
|
||||
# Inventory row exists as approved (already on canvas).
|
||||
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
assert inv.status == "approved"
|
||||
# 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(PendingDevice(
|
||||
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"],
|
||||
@@ -257,7 +269,7 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
# 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(PendingDevice))).scalars().all()
|
||||
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
|
||||
@@ -265,11 +277,139 @@ async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
||||
assert set(row.discovery_sources) == {"arp", "proxmox"} # shows in both filters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_never_overwrites_another_guest_row_on_a_shared_ip(db_session) -> None:
|
||||
# Issue #419: two guests report the same address (duplicate lease, re-used
|
||||
# DHCP, or a container bridge address the agent lists first). Importing 812
|
||||
# used to match 802's row on that IP and overwrite its hostname and specs,
|
||||
# leaving no row for the original guest.
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="pve-pve1-802", ip="10.0.0.5",
|
||||
hostname="vm802", suggested_type="vm", status="pending",
|
||||
cpu_count=8, ram_gb=32.0, disk_gb=500.0,
|
||||
discovery_source="proxmox", discovery_sources=["proxmox"],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, [_guest_node(812, "10.0.0.5")], [])
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 2 # 802 still its own row
|
||||
kept = next(r for r in rows if r.ieee_address == "pve-pve1-802")
|
||||
assert kept.hostname == "vm802" # not stomped by 812
|
||||
assert (kept.cpu_count, kept.ram_gb, kept.disk_gb) == (8, 32.0, 500.0)
|
||||
added = next(r for r in rows if r.ieee_address == "pve-pve1-812")
|
||||
assert added.hostname == "vm812"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_matches_ieee_before_a_shared_ip(db_session) -> None:
|
||||
# Both rows exist and share an IP. The guest's own ieee wins over the other
|
||||
# row's matching address, whichever order the database yields them in.
|
||||
for vmid, hostname in ((802, "vm802"), (812, "stale")):
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address=f"pve-pve1-{vmid}", ip="10.0.0.5",
|
||||
hostname=hostname, suggested_type="vm", status="pending",
|
||||
discovery_source="proxmox", discovery_sources=["proxmox"],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(db_session, [_guest_node(812, "10.0.0.5")], [])
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 2
|
||||
assert next(r for r in rows if r.ieee_address == "pve-pve1-802").hostname == "vm802"
|
||||
assert next(r for r in rows if r.ieee_address == "pve-pve1-812").hostname == "vm812"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_the_oldest_scan_row_when_two_share_an_ip(db_session) -> None:
|
||||
# Unclaimed scan rows on one address: pick deterministically (oldest), so a
|
||||
# re-import lands on the same row instead of whichever comes back first.
|
||||
for offset, mac in ((2, "aa:bb:cc:00:00:01"), (1, "aa:bb:cc:00:00:02")):
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", mac=mac,
|
||||
suggested_type="generic", status="pending",
|
||||
discovered_at=datetime(2026, 1, offset, tzinfo=timezone.utc),
|
||||
discovery_source="arp", discovery_sources=["arp"],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
for _ in range(2):
|
||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 2 # merged, never a third
|
||||
merged = next(r for r in rows if r.ieee_address == "pve-pve1-101")
|
||||
assert merged.mac == "aa:bb:cc:00:00:02" # the older row
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_a_guest_that_moved_to_another_host(db_session) -> None:
|
||||
# The synthetic ieee embeds the host name, so a live migration or a node
|
||||
# rename changes it for what is the same guest. The NIC MAC survives both:
|
||||
# merge and re-point the row, rather than filing a new device and orphaning
|
||||
# the old row (which would then never resolve as a host→guest link end).
|
||||
await _persist_pending_import(
|
||||
db_session, [_guest_node(802, "10.0.0.5", mac="bc:24:11:aa:bb:cc")], []
|
||||
)
|
||||
row = (await db_session.execute(select(InventoryDevice))).scalars().one()
|
||||
row.status = "approved"
|
||||
await db_session.commit()
|
||||
|
||||
await _persist_pending_import(
|
||||
db_session,
|
||||
[_guest_node(802, "10.0.0.5", mac="bc:24:11:aa:bb:cc", host="pve2")],
|
||||
[],
|
||||
)
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1 # no duplicate, no orphan
|
||||
assert rows[0].ieee_address == "pve-pve2-802" # re-pointed to the new host
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_never_repoints_a_mesh_ieee(db_session) -> None:
|
||||
# A zigbee/zwave ieee is a real hardware address. A Proxmox import that
|
||||
# merges into such a row by MAC must not overwrite it.
|
||||
db_session.add(InventoryDevice(
|
||||
id=str(uuid.uuid4()), ieee_address="0x00124b0022a1b2c3",
|
||||
mac="bc:24:11:aa:bb:cc", suggested_type="zigbee_device", status="pending",
|
||||
discovery_source="zigbee", discovery_sources=["zigbee"],
|
||||
))
|
||||
await db_session.commit()
|
||||
|
||||
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
|
||||
assert rows[0].ieee_address == "0x00124b0022a1b2c3"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persist_merges_two_guests_that_share_a_nic_mac(db_session) -> None:
|
||||
# Accepted trade-off of matching on MAC: two guests configured with the same
|
||||
# NIC MAC collapse into one row. That is a misconfiguration which already
|
||||
# breaks their networking, and the alternative — excluding claimed rows from
|
||||
# the MAC fallback — orphans every migrated guest. Shared *IPs* stay split;
|
||||
# see test_persist_never_overwrites_another_guest_row_on_a_shared_ip.
|
||||
nodes = [
|
||||
_guest_node(802, "10.0.0.5", mac="bc:24:11:aa:bb:cc"),
|
||||
_guest_node(812, "10.0.0.6", mac="bc:24:11:aa:bb:cc"),
|
||||
]
|
||||
await _persist_pending_import(db_session, nodes, [])
|
||||
|
||||
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@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(PendingDevice(
|
||||
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=[],
|
||||
@@ -280,7 +420,7 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
|
||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
||||
)
|
||||
|
||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
||||
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"}
|
||||
@@ -290,17 +430,17 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
|
||||
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.
|
||||
node = Node(
|
||||
id=str(uuid.uuid4()), type="lxc", label="immich",
|
||||
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
||||
status="online", pos_x=0, pos_y=0,
|
||||
)
|
||||
inv = PendingDevice(
|
||||
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_all([node, inv])
|
||||
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(
|
||||
@@ -308,7 +448,7 @@ async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -
|
||||
)
|
||||
|
||||
inv_row = (await db_session.execute(
|
||||
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
|
||||
select(InventoryDevice).where(InventoryDevice.mac == "bc:24:11:6c:96:52")
|
||||
)).scalar_one()
|
||||
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
|
||||
|
||||
@@ -320,7 +460,7 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
|
||||
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(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101")
|
||||
)).scalar_one()
|
||||
assert set(row.discovery_sources) == {"proxmox"}
|
||||
|
||||
@@ -328,9 +468,15 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
|
||||
@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",
|
||||
mac="bc:24:11:aa:bb:cc", status="online", pos_x=0, pos_y=0,
|
||||
device_id=scanned_device.id, pos_x=0, pos_y=0,
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
@@ -339,7 +485,8 @@ async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
|
||||
|
||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
||||
assert len(nodes) == 1 # no duplicate node
|
||||
merged = nodes[0]
|
||||
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
|
||||
@@ -351,27 +498,27 @@ async def test_persist_resync_updates_in_place(db_session) -> None:
|
||||
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(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
|
||||
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(PendingDevice(
|
||||
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(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
||||
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 = PendingDevice(
|
||||
dev = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
|
||||
status="pending", discovery_source="arp",
|
||||
)
|
||||
@@ -396,7 +543,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||
await _persist_pending_import(db_session, nodes, edges)
|
||||
|
||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
||||
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")
|
||||
@@ -410,7 +557,7 @@ async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
||||
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(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
|
||||
select(InventoryDeviceLink).where(InventoryDeviceLink.discovery_source == "proxmox_cluster")
|
||||
)).scalars().all()
|
||||
assert links == []
|
||||
|
||||
@@ -420,14 +567,16 @@ 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)
|
||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
|
||||
status="online", 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", ieee_address="pve-node-b",
|
||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
||||
left_handles=1, right_handles=1)
|
||||
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(PendingDeviceLink(
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
||||
discovery_source="proxmox_cluster",
|
||||
))
|
||||
@@ -451,15 +600,20 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
|
||||
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", ieee_address="0xAAAA",
|
||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
||||
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
|
||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
||||
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(PendingDeviceLink(
|
||||
db_session.add(InventoryDeviceLink(
|
||||
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
|
||||
discovery_source="zigbee",
|
||||
))
|
||||
@@ -476,7 +630,7 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
|
||||
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(PendingDeviceLink))).scalars().one()
|
||||
assert (await db_session.execute(select(InventoryDeviceLink))).scalars().one()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -484,6 +638,65 @@ 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(PendingDevice))).scalars().all()
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_import_also_lands_in_device_inventory(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
"""A canvas import is an import: the guests reach the Device Inventory too."""
|
||||
nodes = [_host_node(), _guest_node(101, "10.0.0.5")]
|
||||
with patch("app.api.routes.proxmox.fetch_proxmox_inventory") as mock_fetch:
|
||||
mock_fetch.return_value = (nodes, [])
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/import",
|
||||
json={
|
||||
"host": "pve1",
|
||||
"token_id": "root@pam!t",
|
||||
"token_secret": "s",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.discovery_source == "proxmox")
|
||||
)
|
||||
).scalars().all()
|
||||
by_ieee = {r.ieee_address: r for r in rows}
|
||||
assert set(by_ieee) == {"pve-node-pve1", "pve-pve1-101"}
|
||||
for node in res.json()["nodes"]:
|
||||
assert node["device_id"] == by_ieee[node["ieee_address"]].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_import_points_at_the_row_matched_by_ip(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
"""A guest merged into a scanned row by IP draws that row, not a new one."""
|
||||
scanned = InventoryDevice(
|
||||
id=str(uuid.uuid4()), ip="10.0.0.5", suggested_type="generic",
|
||||
status="pending", discovery_source="arp", discovery_sources=["arp"],
|
||||
)
|
||||
db_session.add(scanned)
|
||||
await db_session.commit()
|
||||
|
||||
with patch("app.api.routes.proxmox.fetch_proxmox_inventory") as mock_fetch:
|
||||
mock_fetch.return_value = ([_guest_node(101, "10.0.0.5")], [])
|
||||
res = await client.post(
|
||||
"/api/v1/proxmox/import",
|
||||
json={"host": "pve1", "token_id": "root@pam!t", "token_secret": "s"},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["nodes"][0]["device_id"] == scanned.id
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ip == "10.0.0.5")
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
@@ -30,6 +30,28 @@ def test_extract_qemu_ip_skips_loopback() -> None:
|
||||
assert svc._extract_qemu_ip({"result": []}) is None
|
||||
|
||||
|
||||
def test_extract_qemu_ip_prefers_the_configured_nic() -> None:
|
||||
# A guest running k8s/Docker: the agent lists the CNI bridge first, and that
|
||||
# address repeats across guests. Pick the interface holding the NIC MAC from
|
||||
# /config instead (issue #419).
|
||||
payload = {
|
||||
"result": [
|
||||
{
|
||||
"name": "cni0", "hardware-address": "0a:58:0a:2a:00:01",
|
||||
"ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.42.0.1"}],
|
||||
},
|
||||
{
|
||||
"name": "eth0", "hardware-address": "BC:24:11:AA:BB:CC",
|
||||
"ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "192.168.1.20"}],
|
||||
},
|
||||
]
|
||||
}
|
||||
assert svc._extract_qemu_ip(payload, "bc:24:11:aa:bb:cc") == "192.168.1.20"
|
||||
# No MAC known, or none matching: first non-loopback IPv4, as before.
|
||||
assert svc._extract_qemu_ip(payload) == "10.42.0.1"
|
||||
assert svc._extract_qemu_ip(payload, "de:ad:be:ef:00:00") == "10.42.0.1"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -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)}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
"""GET /api/v1/scan/pending/{id}/proxmox-children.
|
||||
|
||||
Resolves the host -> guest links the Proxmox import records into the inventory
|
||||
rows the UI needs to offer "add the guests too" when a host reaches a canvas.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import InventoryDevice, InventoryDeviceLink
|
||||
|
||||
|
||||
async def _seed(db: AsyncSession) -> dict[str, str]:
|
||||
host = InventoryDevice(
|
||||
ieee_address="pve-node-pve1", hostname="pve1", suggested_type="proxmox", status="pending"
|
||||
)
|
||||
vm = InventoryDevice(
|
||||
ieee_address="pve-pve1-101", hostname="web", suggested_type="vm", status="pending"
|
||||
)
|
||||
lxc = InventoryDevice(
|
||||
ieee_address="pve-pve1-102", hostname="dns", suggested_type="lxc", status="pending"
|
||||
)
|
||||
other = InventoryDevice(ip="192.168.1.9", hostname="nas", status="pending")
|
||||
db.add_all([host, vm, lxc, other])
|
||||
await db.flush()
|
||||
db.add_all([
|
||||
InventoryDeviceLink(
|
||||
source_ieee="pve-node-pve1", target_ieee="pve-pve1-101", discovery_source="proxmox"
|
||||
),
|
||||
InventoryDeviceLink(
|
||||
source_ieee="pve-node-pve1", target_ieee="pve-pve1-102", discovery_source="proxmox"
|
||||
),
|
||||
])
|
||||
await db.commit()
|
||||
return {"host": host.id, "vm": vm.id, "lxc": lxc.id, "other": other.id}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_the_guests_of_a_host(client: AsyncClient, headers, db_session):
|
||||
ids = await _seed(db_session)
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert {d["id"] for d in res.json()} == {ids["vm"], ids["lxc"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_for_a_guest(client: AsyncClient, headers, db_session):
|
||||
ids = await _seed(db_session)
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['vm']}/proxmox-children", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_for_a_device_with_no_ieee(client: AsyncClient, headers, db_session):
|
||||
ids = await _seed(db_session)
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['other']}/proxmox-children", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skips_hidden_guests(client: AsyncClient, headers, db_session):
|
||||
ids = await _seed(db_session)
|
||||
lxc = await db_session.get(InventoryDevice, ids["lxc"])
|
||||
lxc.status = "hidden"
|
||||
await db_session.commit()
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
|
||||
assert [d["id"] for d in res.json()] == [ids["vm"]]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignores_cluster_links(client: AsyncClient, headers, db_session):
|
||||
"""A host↔host cluster link is not a parent/child relation."""
|
||||
ids = await _seed(db_session)
|
||||
peer = InventoryDevice(
|
||||
ieee_address="pve-node-pve2", hostname="pve2", suggested_type="proxmox", status="pending"
|
||||
)
|
||||
db_session.add(peer)
|
||||
db_session.add(
|
||||
InventoryDeviceLink(
|
||||
source_ieee="pve-node-pve1",
|
||||
target_ieee="pve-node-pve2",
|
||||
discovery_source="proxmox_cluster",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children", headers=headers)
|
||||
assert {d["id"] for d in res.json()} == {ids["vm"], ids["lxc"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_404_for_an_unknown_device(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/pending/nope/proxmox-children", headers=headers)
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_requires_auth(client: AsyncClient, db_session):
|
||||
ids = await _seed(db_session)
|
||||
res = await client.get(f"/api/v1/scan/pending/{ids['host']}/proxmox-children")
|
||||
assert res.status_code == 401
|
||||
+738
-31
@@ -1,5 +1,6 @@
|
||||
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -8,7 +9,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
|
||||
@@ -375,6 +376,79 @@ def test_nmap_scan_single_non_root_uses_connect_scan():
|
||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
||||
|
||||
|
||||
def test_nmap_scan_single_discovery_is_unbounded_by_default():
|
||||
"""The range scan's discovery pass keeps its authoritative, untimed run."""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.14", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.14", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "--host-timeout" not in args
|
||||
assert "--min-rate" not in args
|
||||
|
||||
|
||||
def test_nmap_scan_single_bounded_never_sets_a_host_timeout():
|
||||
"""A deep slice drops retries — never --host-timeout.
|
||||
|
||||
nmap answers a host timeout with "Skipping host <ip> due to host timeout"
|
||||
and throws away every port it had already found, so a ceiling here turns a
|
||||
slow scan into one that reports nothing. The time on a dropping host goes to
|
||||
the retry pass — measured 2x — so that is what the deep scan gives up.
|
||||
"""
|
||||
from app.services.scanner import _nmap_scan_single
|
||||
|
||||
host = {"ip": "192.168.1.15", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
||||
disc = _fake_scanner("192.168.1.15", {})
|
||||
|
||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
||||
patch("app.services.scanner.os.geteuid", return_value=1000):
|
||||
_nmap_scan_single(host, "1-8192", True)
|
||||
|
||||
args = disc.scan.call_args.kwargs["arguments"]
|
||||
assert "-p 1-8192" in args
|
||||
assert "--host-timeout" not in args
|
||||
assert "--max-retries 0" in args
|
||||
|
||||
|
||||
def test_deep_port_chunks_cover_every_port_once():
|
||||
from app.services.scanner import _deep_port_chunks
|
||||
|
||||
chunks = _deep_port_chunks(8192)
|
||||
assert chunks[0] == "1-8192"
|
||||
assert chunks[-1].endswith("-65535")
|
||||
covered = []
|
||||
for c in chunks:
|
||||
lo, hi = (int(x) for x in c.split("-"))
|
||||
covered.extend(range(lo, hi + 1))
|
||||
assert covered == list(range(1, 65536))
|
||||
|
||||
|
||||
def test_port_chunks_pack_ranges_and_honour_the_slice_size():
|
||||
from app.services.scanner import _port_chunks
|
||||
|
||||
# Small ranges share one call instead of one call each.
|
||||
assert _port_chunks("80,443,8000-9000") == ["80,443,8000-9000"]
|
||||
# A range wider than the slice is cut at the slice boundary.
|
||||
assert _port_chunks("1-100", 40) == ["1-40", "41-80", "81-100"]
|
||||
# Overlapping input is merged before slicing, so no port is scanned twice.
|
||||
assert _port_chunks("1-100,50-200", 1000) == ["1-200"]
|
||||
assert _port_chunks("nonsense") == []
|
||||
|
||||
|
||||
def test_parse_port_spec_rejects_what_nmap_could_not_use():
|
||||
from app.services.scanner import _parse_port_spec, _valid_port_spec
|
||||
|
||||
for bad in ["", " ", "0", "65536", "100-50", "80,", "http", "-80"]:
|
||||
assert _parse_port_spec(bad) == [], bad
|
||||
assert _valid_port_spec(bad) is False, bad
|
||||
assert _valid_port_spec("80,443,8000-9000") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _nmap_scan
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -588,7 +662,7 @@ 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)
|
||||
@@ -596,13 +670,20 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
|
||||
"""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))
|
||||
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
|
||||
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": []}]
|
||||
@@ -614,21 +695,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n1")
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
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 node with no IP but a matching MAC still gets last_scan stamped."""
|
||||
"""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))
|
||||
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
|
||||
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": []}]
|
||||
@@ -640,21 +724,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n2")
|
||||
device = await session.get(InventoryDevice, "d2")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is not None
|
||||
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 node whose IP/MAC is not seen by the scan keeps last_scan = None."""
|
||||
"""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))
|
||||
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
|
||||
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": []}]
|
||||
@@ -666,15 +753,15 @@ async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
node = await session.get(Node, "n3")
|
||||
device = await session.get(InventoryDevice, "d3")
|
||||
|
||||
assert node is not None
|
||||
assert node.last_scan is None
|
||||
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()
|
||||
@@ -691,7 +778,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
|
||||
@@ -709,7 +796,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
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(PendingDevice(
|
||||
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"],
|
||||
@@ -727,7 +814,7 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
|
||||
rows = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert len(rows) == 1 # merged, not duplicated
|
||||
row = rows[0]
|
||||
@@ -737,6 +824,48 @@ async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
||||
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_keeps_services_the_fingerprint_cannot_see(mem_db):
|
||||
"""A re-scan unions its fingerprint onto the row — it never replaces it.
|
||||
|
||||
Since 3.3.0 the row is the only copy of a device's services and every canvas
|
||||
drawing it reads that list, so overwriting it with what nmap happened to
|
||||
match would delete hand-added services everywhere at once (#347).
|
||||
"""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
session.add(InventoryDevice(
|
||||
id="row-1", ip="192.168.1.60", status="approved",
|
||||
discovery_source="arp", discovery_sources=["arp"],
|
||||
services=[
|
||||
{"port": 9000, "protocol": "tcp", "service_name": "Portainer", "path": "/#!/home"},
|
||||
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
|
||||
],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
nmap_hosts = [{"ip": "192.168.1.60", "hostname": "docker.lan", "mac": None, "os": None,
|
||||
"open_ports": [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]}]
|
||||
|
||||
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:
|
||||
row = await session.get(InventoryDevice, "row-1")
|
||||
|
||||
by_port = {s["port"]: s for s in row.services}
|
||||
assert by_port[9000]["service_name"] == "Portainer" # hand-added, untouched
|
||||
assert by_port[9000]["path"] == "/#!/home"
|
||||
assert 22 in by_port # what the scan saw is still there
|
||||
assert row.status == "approved"
|
||||
|
||||
|
||||
@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."""
|
||||
@@ -756,7 +885,7 @@ 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
|
||||
@@ -771,11 +900,12 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
|
||||
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": []}]
|
||||
@@ -787,7 +917,7 @@ async def test_run_scan_keeps_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"))
|
||||
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"
|
||||
@@ -801,7 +931,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()
|
||||
|
||||
@@ -815,7 +945,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
|
||||
|
||||
@@ -943,7 +1073,7 @@ async def test_run_scan_probe_enriches_services(mem_db):
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
|
||||
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
|
||||
@@ -974,3 +1104,580 @@ async def test_run_scan_no_probe_when_disabled(mem_db):
|
||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
||||
|
||||
probe.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_device_scan — per-device deep rescan (issue #350)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_build_port_spec_full_covers_every_tcp_port():
|
||||
from app.services.scanner import _build_port_spec
|
||||
|
||||
# full wins over the curated list *and* over user ranges — the deep rescan
|
||||
# is only worth its minutes if it really scans everything.
|
||||
assert _build_port_spec(None, full=True) == "1-65535"
|
||||
assert _build_port_spec(["8000-8100"], full=True) == "1-65535"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_refreshes_services_and_marks_run_done(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[]))
|
||||
await session.commit()
|
||||
|
||||
seen_specs = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
seen_specs.append(port_spec)
|
||||
# A full-range rescan is always bounded, or it never ends.
|
||||
assert bounded is True
|
||||
# Only the slice holding 22 reports it — the union is the caller's job.
|
||||
if port_spec == "1-8192":
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert run.status == "done"
|
||||
assert device.last_scan is not None
|
||||
assert any(s.get("port") == 22 for s in device.services)
|
||||
# A rescan refreshes an existing row; it never spawns a second one.
|
||||
assert device.status == "approved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_hand_added_services(mem_db):
|
||||
"""Regression: the rescan unions, it does not replace.
|
||||
|
||||
Services the user typed in by hand are the only copy that exists — every
|
||||
canvas drawing the device reads this list.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
hand_added = {"port": 9000, "protocol": "tcp", "service_name": "My App"}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[hand_added]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
ports = {s.get("port") for s in device.services}
|
||||
assert ports == {22, 9000}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_keeps_the_icon_the_user_picked_for_a_service(mem_db):
|
||||
"""Regression: every scan used to repaint hand-picked service icons.
|
||||
|
||||
The fingerprint guesses an icon from the port; the user's choice is the
|
||||
only one that means anything, so a rescan leaves it where it is.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
curated = {
|
||||
"port": 22,
|
||||
"protocol": "tcp",
|
||||
"service_name": "ssh",
|
||||
"icon": "brand:openssh",
|
||||
"category": "remote",
|
||||
}
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="approved", services=[curated]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": "OpenSSH 9.2"}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
matches = [s for s in device.services if s.get("port") == 22]
|
||||
# Merged in place — the name key is case-insensitive, so "SSH" from the
|
||||
# signature does not append a second row next to the user's "ssh".
|
||||
assert len(matches) == 1
|
||||
ssh = matches[0]
|
||||
assert ssh["icon"] == "brand:openssh"
|
||||
assert ssh["category"] == "remote"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_the_device_own_discovery_source(mem_db):
|
||||
"""A rescan re-observes a device; it does not discover it on the network.
|
||||
|
||||
Tagging every rescanned device "arp" told a Proxmox guest or a hand-added
|
||||
host that the network scanner found it, and it then answered that filter.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(
|
||||
id="d1",
|
||||
ip="192.168.1.9",
|
||||
status="approved",
|
||||
services=[],
|
||||
discovery_source="proxmox",
|
||||
discovery_sources=["proxmox"],
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
host_dict["open_ports"] = [{"port": 8006, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8006")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
|
||||
assert device is not None
|
||||
assert device.discovery_sources == ["proxmox"]
|
||||
assert device.discovery_source == "proxmox"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_marks_run_error_when_device_has_no_ip(mem_db):
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip=None, status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert run is not None
|
||||
assert run.status == "error"
|
||||
assert run.error is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_skips_nmap_when_cancelled(mem_db):
|
||||
from app.services.scanner import _cancelled_runs, request_cancel, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
request_cancel(run_id)
|
||||
single = MagicMock()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", new=single), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
single.assert_not_called()
|
||||
assert run is not None
|
||||
assert run.status == "cancelled"
|
||||
# The run cleans up its cancellation flag on the way out.
|
||||
assert run_id not in _cancelled_runs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_unions_ports_across_slices(mem_db):
|
||||
"""Every slice contributes; a slow one costs only its own ports."""
|
||||
from app.services.scanner import _deep_port_chunks, run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
lo = int(port_spec.split("-")[0])
|
||||
if lo == 1:
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
elif lo == 8193:
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22, 8096}
|
||||
# A complete sweep carries no advisory.
|
||||
assert run.error is None
|
||||
assert len(_deep_port_chunks()) == 8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_honours_a_requested_port_range(mem_db):
|
||||
"""A user-chosen range replaces the full sweep — and skips retry-free timing.
|
||||
|
||||
A handful of ports is cheap enough to scan properly; the bounded flags only
|
||||
pay off over thousands of them.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append((port_spec, bounded))
|
||||
host_dict["open_ports"] = [{"port": 8096, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
await run_device_scan("d1", session, run_id, ports="8000-9000")
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == [("8000-9000", False)]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {8096}
|
||||
assert run.status == "done"
|
||||
assert run.error is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_device_scan_keeps_what_it_found_when_the_budget_runs_out(mem_db):
|
||||
"""A spent budget stops the sweep — it never discards the ports found.
|
||||
|
||||
The earlier --host-timeout did exactly that (nmap skips the host wholesale),
|
||||
which is why a deep scan could come back empty on a slow host.
|
||||
"""
|
||||
from app.services.scanner import run_device_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(ScanRun(id=run_id, status="running", kind="device", ranges=["192.168.1.9/32"]))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.9", status="pending", services=[]))
|
||||
await session.commit()
|
||||
|
||||
calls = []
|
||||
|
||||
def _scanned(host_dict, port_spec, bounded=False):
|
||||
calls.append(port_spec)
|
||||
host_dict["open_ports"] = [{"port": 22, "protocol": "tcp", "banner": ""}]
|
||||
return host_dict
|
||||
|
||||
async with mem_db() as session:
|
||||
# Budget already spent when the first slice returns.
|
||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_scanned), \
|
||||
patch("app.services.scanner.settings") as mock_settings, \
|
||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
||||
mock_settings.scanner_deep_host_timeout = -1
|
||||
await run_device_scan("d1", session, run_id)
|
||||
|
||||
async with mem_db() as session:
|
||||
device = await session.get(InventoryDevice, "d1")
|
||||
run = await session.get(ScanRun, run_id)
|
||||
|
||||
assert calls == ["1-8192"]
|
||||
assert device is not None and run is not None
|
||||
assert {s.get("port") for s in device.services} == {22}
|
||||
assert run.status == "done"
|
||||
# Partial coverage is reported, never passed off as a full sweep.
|
||||
assert run.error is not None
|
||||
assert "1/8" in run.error
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-IP devices — one row per device, not one per address (issue #373)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _host(ip: str, *, mac: str | None = None, hostname: str | None = None):
|
||||
return {"ip": ip, "hostname": hostname, "mac": mac, "os": None, "open_ports": []}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_host_folds_into_declared_secondary_ip(mem_db):
|
||||
"""A scan of a device's second address refreshes its row, never mints one.
|
||||
|
||||
The user declares the extra addresses by hand — a router's LAN and VLAN
|
||||
legs, a host behind a VIP — precisely so the scanner stops reporting them
|
||||
as separate discoveries.
|
||||
"""
|
||||
from app.services.scanner import DeepScanOptions, process_host
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(
|
||||
id="d1", ip="192.168.1.1, 192.168.10.1", status="approved", label="router",
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
outcome = await process_host(
|
||||
session, _host("192.168.10.1", hostname="router-vlan.lan"),
|
||||
hidden_ips=set(), deep_scan=DeepScanOptions(),
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert outcome == "updated"
|
||||
assert len(devices) == 1
|
||||
# The declared list is the user's, not the scanner's — it survives the merge.
|
||||
assert devices[0].ip == "192.168.1.1, 192.168.10.1"
|
||||
assert devices[0].hostname == "router-vlan.lan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_host_does_not_match_an_address_prefix(mem_db):
|
||||
"""`192.168.1.4` is a substring of `192.168.1.40` — and a different host.
|
||||
|
||||
The query narrows with a substring match, so the per-token confirmation is
|
||||
what stops a false match. It matters more than usual here: the losing rows
|
||||
of a match are deleted, not skipped.
|
||||
"""
|
||||
from app.services.scanner import DeepScanOptions, process_host
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.40", status="approved"))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
outcome = await process_host(
|
||||
session, _host("192.168.1.4"), hidden_ips=set(), deep_scan=DeepScanOptions(),
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert outcome == "created"
|
||||
assert {d.ip for d in devices} == {"192.168.1.40", "192.168.1.4"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_host_matches_a_single_token_row_exactly_as_before(mem_db):
|
||||
"""The ordinary one-address case is untouched by the token matching."""
|
||||
from app.services.scanner import DeepScanOptions, process_host
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.5", status="pending"))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
outcome = await process_host(
|
||||
session, _host("192.168.1.5", hostname="nas.lan"),
|
||||
hidden_ips=set(), deep_scan=DeepScanOptions(),
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert outcome == "updated"
|
||||
assert len(devices) == 1 and devices[0].hostname == "nas.lan"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_scan_skips_the_secondary_ip_of_a_hidden_device(mem_db):
|
||||
"""Hiding a multi-address device hides it at every one of its addresses."""
|
||||
from app.services.scanner import run_scan
|
||||
|
||||
run_id = _make_run_id()
|
||||
async with mem_db() as session:
|
||||
session.add(_make_scan_run(run_id))
|
||||
session.add(InventoryDevice(id="d1", ip="192.168.1.1, 192.168.10.1", status="hidden"))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
with patch("app.services.scanner._nmap_scan", return_value=[_host("192.168.10.1")]), \
|
||||
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:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert len(devices) == 1
|
||||
assert devices[0].status == "hidden"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedupe_collapses_a_single_ip_row_into_a_multi_ip_one(mem_db):
|
||||
"""Declaring an extra address folds in the duplicate row it already spawned.
|
||||
|
||||
The approved row wins even though it is the younger of the two: it carries
|
||||
the canvas links.
|
||||
"""
|
||||
from app.services.scanner import _dedupe_pending_by_ip
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(
|
||||
id="stale", ip="192.168.10.1", status="pending",
|
||||
discovered_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
session.add(InventoryDevice(
|
||||
id="keep", ip="192.168.1.1, 192.168.10.1", status="approved",
|
||||
discovered_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
deleted = await _dedupe_pending_by_ip(session)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert deleted == 1
|
||||
assert [d.id for d in devices] == ["keep"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedupe_leaves_rows_that_share_no_address(mem_db):
|
||||
from app.services.scanner import _dedupe_pending_by_ip
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(id="a", ip="192.168.1.1, 192.168.10.1", status="pending"))
|
||||
session.add(InventoryDevice(id="b", ip="192.168.1.2, 192.168.10.2", status="pending"))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
deleted = await _dedupe_pending_by_ip(session)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert deleted == 0
|
||||
assert len(devices) == 2
|
||||
|
||||
|
||||
def test_group_by_shared_ip_is_transitive():
|
||||
"""`A,B` next to `A` and `B` is one device, not two pairs."""
|
||||
from app.services.scanner import _group_by_shared_ip
|
||||
|
||||
rows = [
|
||||
InventoryDevice(id="a", ip="10.0.0.1"),
|
||||
InventoryDevice(id="b", ip="10.0.1.1"),
|
||||
InventoryDevice(id="bridge", ip="10.0.0.1, 10.0.1.1"),
|
||||
InventoryDevice(id="other", ip="10.0.2.1"),
|
||||
]
|
||||
|
||||
groups = {frozenset(r.id for r in group) for group in _group_by_shared_ip(rows)}
|
||||
|
||||
assert groups == {frozenset({"a", "b", "bridge"}), frozenset({"other"})}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedupe_never_deletes_a_second_approved_row(mem_db):
|
||||
"""Two approved rows sharing an address both keep their canvas links.
|
||||
|
||||
An approved row is what nodes and rack mounts point at, so collapsing one
|
||||
into the other would silently unlink whatever drew it. The scan collapses
|
||||
the pending row and leaves the pair for the user to resolve.
|
||||
"""
|
||||
from app.services.scanner import _dedupe_pending_by_ip
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(
|
||||
id="a", ip="10.0.0.1", status="approved",
|
||||
discovered_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
session.add(InventoryDevice(
|
||||
id="b", ip="10.0.0.1, 10.0.0.2", status="approved",
|
||||
discovered_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
session.add(InventoryDevice(
|
||||
id="pending", ip="10.0.0.2", status="pending",
|
||||
discovered_at=datetime(2026, 3, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
deleted = await _dedupe_pending_by_ip(session)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
|
||||
assert deleted == 1
|
||||
assert sorted(d.id for d in devices) == ["a", "b"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_host_never_deletes_a_second_approved_row(mem_db):
|
||||
"""The same guard on the scan path: the merge target refreshes, no row goes."""
|
||||
from app.services.scanner import DeepScanOptions, process_host
|
||||
|
||||
async with mem_db() as session:
|
||||
session.add(InventoryDevice(
|
||||
id="a", ip="10.0.0.1", status="approved",
|
||||
discovered_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
session.add(InventoryDevice(
|
||||
id="b", ip="10.0.0.1, 10.0.0.2", status="approved",
|
||||
discovered_at=datetime(2026, 2, 1, tzinfo=timezone.utc),
|
||||
))
|
||||
await session.commit()
|
||||
|
||||
async with mem_db() as session:
|
||||
outcome = await process_host(
|
||||
session, _host("10.0.0.1", hostname="claimed.lan"),
|
||||
hidden_ips=set(), deep_scan=DeepScanOptions(),
|
||||
)
|
||||
|
||||
async with mem_db() as session:
|
||||
devices = (await session.execute(sa_select(InventoryDevice))).scalars().all()
|
||||
oldest = await session.get(InventoryDevice, "a")
|
||||
|
||||
assert outcome == "updated"
|
||||
assert sorted(d.id for d in devices) == ["a", "b"]
|
||||
# The oldest approved row is the merge target.
|
||||
assert oldest is not None and oldest.hostname == "claimed.lan"
|
||||
|
||||
@@ -24,18 +24,25 @@ from app.core.scheduler import (
|
||||
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,
|
||||
)
|
||||
@@ -58,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), \
|
||||
@@ -72,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}
|
||||
|
||||
@@ -87,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}
|
||||
|
||||
@@ -118,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
|
||||
@@ -191,7 +247,7 @@ def test_start_and_stop_scheduler():
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
session.add(_make_device(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
@@ -203,15 +259,18 @@ async def test_run_service_checks_disabled_does_nothing(mem_db):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
async def test_run_service_checks_broadcasts_per_device(mem_db):
|
||||
async with mem_db() as session:
|
||||
node = _make_node(
|
||||
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()
|
||||
node_id = node.id
|
||||
device_id, node_id = device.id, node.id
|
||||
|
||||
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
||||
|
||||
@@ -224,14 +283,15 @@ async def test_run_service_checks_broadcasts_per_node(mem_db):
|
||||
|
||||
mock_bcast.assert_awaited_once()
|
||||
_, kwargs = mock_bcast.call_args
|
||||
assert kwargs["node_id"] == node_id
|
||||
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_nodes_without_services(mem_db):
|
||||
async def test_run_service_checks_skips_devices_without_services(mem_db):
|
||||
async with mem_db() as session:
|
||||
session.add(_make_node(ip="10.0.0.6", services=[]))
|
||||
session.add(_make_device(ip="10.0.0.6", services=[]))
|
||||
await session.commit()
|
||||
|
||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Detaching nodes recorded as their own parent (#370).
|
||||
|
||||
A ``parent_id = id`` row is fatal on the canvas: every parent walk assumes an
|
||||
acyclic tree, so dragging the node overflowed the stack inside the change
|
||||
reducer and the move was silently dropped — the node selected but would not
|
||||
move. It also renders unparented, sitting wherever its stored coordinates put
|
||||
it rather than inside the container it appears to belong to.
|
||||
|
||||
The repair runs at startup because the bad rows are already in users' databases;
|
||||
the write-path guards shipped alongside it only stop new ones.
|
||||
"""
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
import app.db.database as database
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
_DDL = "CREATE TABLE nodes (id VARCHAR PRIMARY KEY, label VARCHAR, parent_id VARCHAR)"
|
||||
|
||||
|
||||
async def _engine(tmp_path, rows):
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'selfparent.db'}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.exec_driver_sql(_DDL)
|
||||
for node_id, parent_id in rows:
|
||||
await conn.exec_driver_sql(
|
||||
"INSERT INTO nodes (id, label, parent_id) VALUES (?, ?, ?)",
|
||||
(node_id, node_id, parent_id),
|
||||
)
|
||||
return engine
|
||||
|
||||
|
||||
async def _parents(engine) -> dict[str, str | None]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (await conn.exec_driver_sql("SELECT id, parent_id FROM nodes")).fetchall()
|
||||
return {r[0]: r[1] for r in rows}
|
||||
|
||||
|
||||
async def _run(engine, monkeypatch) -> None:
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
await database._repair_self_parent_nodes()
|
||||
|
||||
|
||||
async def test_detaches_a_self_parented_node(tmp_path, monkeypatch):
|
||||
engine = await _engine(tmp_path, [("pihole", "pihole")])
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert (await _parents(engine))["pihole"] is None
|
||||
|
||||
|
||||
async def test_leaves_real_parents_alone(tmp_path, monkeypatch):
|
||||
engine = await _engine(
|
||||
tmp_path,
|
||||
[("pve", None), ("vm", "pve"), ("broken", "broken")],
|
||||
)
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
parents = await _parents(engine)
|
||||
assert parents == {"pve": None, "vm": "pve", "broken": None}
|
||||
|
||||
|
||||
async def test_is_idempotent(tmp_path, monkeypatch):
|
||||
engine = await _engine(tmp_path, [("a", "a"), ("b", None)])
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert await _parents(engine) == {"a": None, "b": None}
|
||||
|
||||
|
||||
async def test_does_not_touch_a_two_node_cycle(tmp_path, monkeypatch):
|
||||
"""Only the exact self-parent case is repairable here.
|
||||
|
||||
A longer cycle has no single right answer for which link to cut, and the
|
||||
runtime cycle guards keep the canvas usable, so the rows are left as they
|
||||
are rather than guessed at.
|
||||
"""
|
||||
engine = await _engine(tmp_path, [("a", "b"), ("b", "a")])
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
|
||||
assert await _parents(engine) == {"a": "b", "b": "a"}
|
||||
|
||||
|
||||
async def test_survives_a_missing_table(tmp_path, monkeypatch):
|
||||
"""Boot must not die on a repair; the canvas works either way."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'empty.db'}")
|
||||
|
||||
await _run(engine, monkeypatch)
|
||||
@@ -37,6 +37,33 @@ 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})
|
||||
@@ -228,3 +255,57 @@ def test_valid_oidc_configuration_is_accepted():
|
||||
configured = _valid_oidc_settings()
|
||||
assert configured.auth_mode == "oidc"
|
||||
assert configured.oidc_cookie_secure is True
|
||||
|
||||
|
||||
# --- Env values arriving still quoted (podman-compose, issue #410) ---
|
||||
|
||||
|
||||
def test_quoted_password_hash_is_unwrapped():
|
||||
"""podman-compose passes .env quotes through verbatim; Docker Compose strips them."""
|
||||
configured = Settings(
|
||||
_env_file=None,
|
||||
secret_key="test-secret",
|
||||
auth_password_hash="'$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'",
|
||||
)
|
||||
assert configured.auth_password_hash == "$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"
|
||||
|
||||
|
||||
def test_double_quoted_password_hash_is_unwrapped():
|
||||
configured = Settings(
|
||||
_env_file=None,
|
||||
secret_key="test-secret",
|
||||
auth_password_hash='"$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"',
|
||||
)
|
||||
assert configured.auth_password_hash.startswith("$2b$12$")
|
||||
|
||||
|
||||
def test_unquoted_password_hash_is_left_alone():
|
||||
raw = "$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG"
|
||||
configured = Settings(_env_file=None, secret_key="test-secret", auth_password_hash=raw)
|
||||
assert configured.auth_password_hash == raw
|
||||
|
||||
|
||||
def test_unmatched_quote_is_not_stripped():
|
||||
"""Only a matched pair is unwrapped, so a truly broken value still fails validation."""
|
||||
configured = Settings(
|
||||
_env_file=None,
|
||||
secret_key="test-secret",
|
||||
auth_password_hash="'$2b$12$RtMbyw17l4N5UGzeXMNAWu",
|
||||
)
|
||||
assert configured.auth_password_hash.startswith("'")
|
||||
|
||||
|
||||
def test_quoted_secret_key_is_unwrapped():
|
||||
configured = _valid_oidc_settings(secret_key="'test-secret-key-that-is-at-least-32-bytes'")
|
||||
assert configured.secret_key == "test-secret-key-that-is-at-least-32-bytes"
|
||||
|
||||
|
||||
def test_secret_key_length_is_checked_after_unwrapping():
|
||||
"""The quotes must not pad a too-short key past the 32-byte OIDC floor."""
|
||||
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
||||
_valid_oidc_settings(secret_key="'" + "a" * 31 + "'")
|
||||
|
||||
|
||||
def test_quoted_oidc_client_secret_is_unwrapped():
|
||||
configured = _valid_oidc_settings(oidc_client_secret="'sup3r$ecret'")
|
||||
assert configured.oidc_client_secret == "sup3r$ecret"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user