Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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'
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -40,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
|
||||
|
||||
@@ -5,6 +5,50 @@ 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
|
||||
|
||||
+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
|
||||
|
||||
+34
-15
@@ -24,13 +24,14 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
|
||||
10. [Z-Wave Import](#10-z-wave-import-)
|
||||
11. [Proxmox VE Import](#11-proxmox-ve-import-)
|
||||
12. [Device Inventory](#12-device-inventory-)
|
||||
13. [Live Status Monitoring](#13-live-status-monitoring-)
|
||||
14. [Export (PNG / SVG / YAML / Markdown)](#14-export)
|
||||
15. [Live View (read-only public canvas)](#15-live-view-)
|
||||
16. [Gethomepage Widget](#16-gethomepage-widget-)
|
||||
17. [MCP Server (AI integration)](#17-mcp-server-)
|
||||
18. [Settings & Shortcuts](#18-settings--shortcuts)
|
||||
19. [Authentication (Local / OpenID Connect)](#19-authentication-local--openid-connect-)
|
||||
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-)
|
||||
|
||||
---
|
||||
|
||||
@@ -187,7 +188,25 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 13. 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.
|
||||
|
||||
@@ -208,7 +227,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 14. Export
|
||||
## 15. Export
|
||||
|
||||
**What:** Get your canvas out as a picture or as structured data.
|
||||
|
||||
@@ -220,7 +239,7 @@ Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge.
|
||||
|
||||
---
|
||||
|
||||
## 15. Live View 🔒
|
||||
## 16. Live View 🔒
|
||||
|
||||
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
|
||||
|
||||
@@ -232,7 +251,7 @@ Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab
|
||||
|
||||
---
|
||||
|
||||
## 16. Gethomepage Widget 🔒
|
||||
## 17. Gethomepage Widget 🔒
|
||||
|
||||
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
|
||||
|
||||
@@ -244,7 +263,7 @@ Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-st
|
||||
|
||||
---
|
||||
|
||||
## 17. 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.
|
||||
|
||||
@@ -258,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).
|
||||
|
||||
---
|
||||
|
||||
## 18. Settings & Shortcuts
|
||||
## 19. Settings & Shortcuts
|
||||
|
||||
**What:** App config and keyboard shortcuts.
|
||||
|
||||
@@ -273,7 +292,7 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
|
||||
|
||||
---
|
||||
|
||||
## 19. 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.
|
||||
|
||||
+272
@@ -63,6 +63,48 @@ 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.
|
||||
@@ -89,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`):
|
||||
|
||||
@@ -35,6 +35,8 @@ Homelable is a self-hosted infrastructure visualization solution. It provides a
|
||||
|
||||
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.
|
||||
@@ -57,7 +59,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, **rack canvases** with port-to-port patching, 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)**.
|
||||
|
||||
@@ -65,7 +67,7 @@ Every feature, with how to turn it on and use it, is described in **[FEATURES.md
|
||||
|
||||
## Installation
|
||||
|
||||
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||
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)**.
|
||||
|
||||
---
|
||||
|
||||
@@ -87,6 +89,28 @@ Gear sits in a U range and part of a 12-column width grid, so half- and third-wi
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
|
||||
---
|
||||
|
||||
## Network Scanner
|
||||
|
||||
The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates a **Pending Devices** queue. From the sidebar you can then approve (adds a node to the canvas), hide, or ignore each discovered device.
|
||||
@@ -110,6 +134,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:
|
||||
@@ -328,8 +360,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
|
||||
|
||||
@@ -405,6 +437,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
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import CanvasState, Design, Edge, Node, Rack, RackCable, RackDevice
|
||||
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
||||
from app.services.doc_links import unlink_documents
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -233,6 +234,8 @@ async def delete_design(
|
||||
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,719 @@
|
||||
"""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
|
||||
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_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("/{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()
|
||||
@@ -8,6 +8,7 @@ from app.api.deps import get_current_user
|
||||
from app.db.database import get_db
|
||||
from app.db.models import Design, InventoryDevice, Node
|
||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||
from app.services.doc_links import unlink_documents
|
||||
from app.services.inventory_sync import (
|
||||
facts_from_payload,
|
||||
facts_from_update,
|
||||
@@ -150,6 +151,11 @@ async def update_node(
|
||||
if not node:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
|
||||
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")
|
||||
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.
|
||||
@@ -173,5 +179,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()
|
||||
|
||||
@@ -33,7 +33,9 @@ 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_device
|
||||
from app.services.proxmox_service import (
|
||||
@@ -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))
|
||||
@@ -247,10 +259,12 @@ async def _persist_pending_import(
|
||||
) -> ProxmoxImportPendingResponse:
|
||||
"""Upsert Proxmox nodes/edges into device_inventory + device_inventory_links.
|
||||
|
||||
Two-tier identity (order matters):
|
||||
1. Match an existing canvas Node or pending row by **IP** (merge into a
|
||||
device previously found by a scan) — never duplicate.
|
||||
2. Else match by synthetic ``ieee_address`` (``pve-...``).
|
||||
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.
|
||||
"""
|
||||
@@ -308,6 +322,12 @@ async def _persist_pending_import(
|
||||
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()
|
||||
|
||||
@@ -322,14 +342,70 @@ async def _persist_pending_import(
|
||||
async def _find_pending(
|
||||
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
|
||||
) -> InventoryDevice | None:
|
||||
filters = [InventoryDevice.ieee_address == ieee]
|
||||
if ip:
|
||||
filters.append(InventoryDevice.ip == ip)
|
||||
if mac:
|
||||
filters.append(InventoryDevice.mac == mac)
|
||||
return (
|
||||
await db.execute(select(InventoryDevice).where(or_(*filters)))
|
||||
"""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(
|
||||
@@ -372,7 +448,7 @@ def _sources_after_merge(row: InventoryDevice) -> 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)
|
||||
@@ -388,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
|
||||
@@ -420,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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TypeVar
|
||||
|
||||
@@ -9,6 +10,7 @@ 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,
|
||||
@@ -124,6 +126,92 @@ def _ip_tokens(ip: str | None) -> list[str]:
|
||||
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)
|
||||
|
||||
|
||||
@@ -165,9 +253,42 @@ async def load_racks(
|
||||
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=[RackDeviceResponse.model_validate(d) for d in devices],
|
||||
devices=mounted,
|
||||
cables=[RackCableResponse.model_validate(c) for c in cables],
|
||||
viewport=state.viewport if state else {"x": 0, "y": 0, "zoom": 1},
|
||||
)
|
||||
@@ -228,11 +349,33 @@ async def save_racks(
|
||||
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:
|
||||
@@ -349,6 +492,11 @@ async def rack_inventory(
|
||||
),
|
||||
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)
|
||||
|
||||
@@ -28,7 +28,9 @@ from app.schemas.scan import (
|
||||
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
|
||||
@@ -116,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
|
||||
@@ -532,6 +541,10 @@ async def clear_pending(
|
||||
_: str = Depends(get_current_user),
|
||||
) -> dict[str, int]:
|
||||
from sqlalchemy import delete as sa_delete
|
||||
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}
|
||||
@@ -563,6 +576,7 @@ async def delete_pending(
|
||||
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}
|
||||
@@ -574,6 +588,86 @@ async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_c
|
||||
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,
|
||||
@@ -1033,6 +1127,7 @@ async def ignore_device(
|
||||
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}
|
||||
|
||||
@@ -30,6 +30,7 @@ from app.schemas.zigbee import (
|
||||
ZigbeeTestConnectionResponse,
|
||||
)
|
||||
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,
|
||||
@@ -130,6 +131,18 @@ async def _background_canvas_import(job_id: str, payload: ZigbeeImportRequest) -
|
||||
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]
|
||||
finish_job(
|
||||
|
||||
@@ -27,6 +27,7 @@ from app.schemas.zwave import (
|
||||
ZwaveTestConnectionRequest,
|
||||
ZwaveTestConnectionResponse,
|
||||
)
|
||||
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,
|
||||
@@ -51,6 +52,7 @@ 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.
|
||||
@@ -82,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))
|
||||
|
||||
@@ -17,6 +17,46 @@ def origin_of(url: str) -> str:
|
||||
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)
|
||||
@@ -61,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(
|
||||
|
||||
+182
-1
@@ -17,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.
|
||||
|
||||
@@ -362,6 +415,18 @@ async def init_db() -> None:
|
||||
"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):
|
||||
@@ -473,6 +538,30 @@ async def init_db() -> None:
|
||||
# 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 (
|
||||
@@ -491,10 +580,19 @@ async def init_db() -> None:
|
||||
):
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -522,6 +620,7 @@ _NODE_COLUMNS_SQL = (
|
||||
"container_mode BOOLEAN,"
|
||||
"custom_colors JSON,"
|
||||
"custom_icon VARCHAR,"
|
||||
"description TEXT,"
|
||||
"show_port_numbers BOOLEAN,"
|
||||
"width FLOAT,"
|
||||
"height FLOAT,"
|
||||
@@ -535,7 +634,7 @@ _NODE_COLUMNS_SQL = (
|
||||
|
||||
_NODE_KEPT = (
|
||||
"id, type, label, design_id, device_id, display_view, pos_x, pos_y, parent_id, container_mode, "
|
||||
"custom_colors, custom_icon, show_port_numbers, width, height, bottom_handles, "
|
||||
"custom_colors, custom_icon, description, show_port_numbers, width, height, bottom_handles, "
|
||||
"top_handles, left_handles, right_handles, created_at, updated_at"
|
||||
)
|
||||
|
||||
@@ -813,6 +912,88 @@ async def _backfill_zone_size() -> None:
|
||||
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]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
@@ -64,6 +64,11 @@ class Node(Base):
|
||||
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)
|
||||
# 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)
|
||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
@@ -172,6 +177,9 @@ class RackDevice(Base):
|
||||
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)
|
||||
@@ -267,6 +275,19 @@ class InventoryDevice(Base):
|
||||
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
|
||||
@@ -308,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)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.api.routes import (
|
||||
auth,
|
||||
canvas,
|
||||
designs,
|
||||
documents,
|
||||
edges,
|
||||
liveview,
|
||||
media,
|
||||
@@ -93,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,6 +1,6 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from app.schemas.edges import EdgeResponse
|
||||
from app.schemas.nodes import NodeResponse
|
||||
@@ -31,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
|
||||
@@ -51,6 +54,22 @@ class NodeSave(BaseModel):
|
||||
pos_x: float = 0
|
||||
pos_y: float = 0
|
||||
|
||||
@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
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,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
|
||||
@@ -61,6 +64,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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -7,6 +7,8 @@ from pydantic import BaseModel, field_validator, model_validator
|
||||
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
|
||||
|
||||
@@ -57,8 +59,15 @@ class RackDeviceSave(BaseModel):
|
||||
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:
|
||||
@@ -161,6 +170,33 @@ class RackSaveRequest(BaseModel):
|
||||
)
|
||||
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
|
||||
@@ -196,8 +232,15 @@ class RackDeviceResponse(BaseModel):
|
||||
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]:
|
||||
@@ -282,6 +325,22 @@ class RackInventoryItem(BaseModel):
|
||||
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
|
||||
|
||||
@@ -3,6 +3,11 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from app.schemas.racks import RACK_COLUMNS
|
||||
|
||||
# Tallest plate the rack canvas offers; `MAX_RACK_U` on the frontend.
|
||||
MAX_PLATE_U = 48
|
||||
|
||||
|
||||
class InventoryDeviceResponse(BaseModel):
|
||||
id: str
|
||||
@@ -57,6 +62,20 @@ class InventoryDeviceResponse(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("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
|
||||
@@ -159,6 +178,36 @@ class InventoryDeviceUpdate(BaseModel):
|
||||
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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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,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
|
||||
@@ -25,7 +25,16 @@ _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:
|
||||
|
||||
@@ -54,11 +54,13 @@ def is_furniture(node_type: str | None) -> bool:
|
||||
return (node_type or "") in FURNITURE_TYPES
|
||||
|
||||
|
||||
def _ip_tokens(ip: str | None) -> list[str]:
|
||||
def ip_tokens(ip: str | None) -> list[str]:
|
||||
"""Split an ``ip`` field into individual addresses.
|
||||
|
||||
A node or device may carry several comma-separated addresses, so identity
|
||||
matching compares per token — same rule as ``node_dedupe._ip_tokens``.
|
||||
A node or device may carry several comma-separated addresses — a router with
|
||||
a management VLAN, a host behind a VIP — so identity matching compares per
|
||||
token rather than on the whole string. Every matcher that resolves a device
|
||||
from addresses shares this rule, the scanner included.
|
||||
"""
|
||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
||||
|
||||
@@ -125,7 +127,7 @@ async def find_device_for(
|
||||
Hidden rows are eligible: a hidden device is still that device, and silently
|
||||
minting a second row for it would resurrect the duplicate the user hid.
|
||||
"""
|
||||
ip_toks = _ip_tokens(ip)
|
||||
ip_toks = ip_tokens(ip)
|
||||
conds = []
|
||||
if ieee:
|
||||
# Case-insensitive: `0x00124B00…` and `0x00124b00…` are the same radio,
|
||||
@@ -151,7 +153,7 @@ async def find_device_for(
|
||||
for device in candidates:
|
||||
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
|
||||
# the SQL `contains`.
|
||||
if ip_toks and set(_ip_tokens(device.ip)) & set(ip_toks):
|
||||
if ip_toks and set(ip_tokens(device.ip)) & set(ip_toks):
|
||||
return device
|
||||
for device in candidates:
|
||||
if mac and device.mac == mac:
|
||||
@@ -579,6 +581,11 @@ async def link_facts(
|
||||
node.device_id = None
|
||||
return None
|
||||
|
||||
# A node that draws a device has no description of its own — what it is for
|
||||
# is written on the inventory row, as `notes`. Clearing here keeps the two
|
||||
# from becoming rival fields for the same text.
|
||||
node.description = None
|
||||
|
||||
device = None
|
||||
if node.device_id:
|
||||
device = await db.get(InventoryDevice, node.device_id)
|
||||
@@ -612,6 +619,12 @@ async def link_facts(
|
||||
)
|
||||
device.discovery_sources = add_source(device.discovery_sources, CANVAS_SOURCE)
|
||||
|
||||
# A row a node draws is past the pending queue — the mirror of the
|
||||
# "approved but no longer drawn -> pending" revival the imports do. A hidden
|
||||
# row stays hidden: the user hid it on purpose.
|
||||
if device.status == "pending":
|
||||
device.status = "approved"
|
||||
|
||||
node.device_id = device.id
|
||||
# Order and visibility are this node's, not the device's, so they are taken
|
||||
# from the full payload — never from the `changed_fields`/`only_changed`
|
||||
@@ -981,3 +994,27 @@ async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
|
||||
|
||||
await db.flush()
|
||||
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
|
||||
|
||||
|
||||
async def attach_device_ids(
|
||||
db: AsyncSession, nodes_raw: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stamp each imported node with the inventory row that already owns it.
|
||||
|
||||
A canvas-direct import upserts the inventory first, so the node dropped on
|
||||
the canvas can point straight at that row instead of minting a second one
|
||||
on the next save. Matching goes through :func:`find_device_for`, so a
|
||||
Proxmox guest merged into a scanned row by ip/mac resolves too.
|
||||
|
||||
Returns new dicts — the caller's payload is left untouched.
|
||||
"""
|
||||
stamped: list[dict[str, Any]] = []
|
||||
for node in nodes_raw:
|
||||
device = await find_device_for(
|
||||
db,
|
||||
ip=node.get("ip"),
|
||||
mac=node.get("mac"),
|
||||
ieee=node.get("ieee_address"),
|
||||
)
|
||||
stamped.append({**node, "device_id": device.id} if device else dict(node))
|
||||
return stamped
|
||||
|
||||
@@ -15,10 +15,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Edge, Node
|
||||
from app.db.models import Document, Edge, Node, RackDevice
|
||||
from app.services.inventory_sync import find_device_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -121,7 +121,11 @@ async def dedupe_nodes_by_device(db: AsyncSession) -> int:
|
||||
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
|
||||
|
||||
all_edges = (
|
||||
await db.execute(
|
||||
@@ -139,6 +143,29 @@ async def dedupe_nodes_by_device(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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -17,10 +17,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
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 merge_services
|
||||
from app.services.inventory_sync import ip_tokens, merge_services
|
||||
from app.services.mac_utils import normalize_mac
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -572,11 +573,66 @@ 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(InventoryDevice)
|
||||
@@ -584,21 +640,16 @@ async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
||||
.order_by(InventoryDevice.discovered_at)
|
||||
)).scalars().all()
|
||||
|
||||
by_ip: dict[str, list[InventoryDevice]] = {}
|
||||
for row in rows:
|
||||
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
||||
continue
|
||||
by_ip.setdefault(row.ip, []).append(row)
|
||||
|
||||
deleted = 0
|
||||
for group in by_ip.values():
|
||||
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
|
||||
@@ -646,23 +697,38 @@ async def process_host(
|
||||
# a duplicate — and so a device previously imported from Proxmox (which
|
||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
||||
# of doubling up. Hidden rows are already skipped above.
|
||||
match_cond = [InventoryDevice.ip == ip]
|
||||
#
|
||||
# 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)
|
||||
existing_rows = (await db.execute(
|
||||
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.
|
||||
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)
|
||||
# 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
|
||||
@@ -736,7 +802,11 @@ async def run_scan(
|
||||
hidden_ips_result = await db.execute(
|
||||
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
|
||||
@@ -794,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:
|
||||
|
||||
@@ -134,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})
|
||||
|
||||
|
||||
@@ -219,7 +226,11 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
||||
) else "http")
|
||||
url_host = _service_host(svc, host)
|
||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
||||
# 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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -421,3 +421,71 @@ async def test_the_newest_backup_that_still_has_the_columns_is_the_one_read(db_3
|
||||
{"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()
|
||||
|
||||
@@ -163,3 +163,78 @@ async def test_legacy_row_defaults_are_coerced(client: AsyncClient, headers, db_
|
||||
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"] == []
|
||||
|
||||
@@ -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}
|
||||
@@ -388,7 +422,7 @@ def test_origin_is_allowed_falls_back_to_the_oidc_redirect_origin(oidc_settings)
|
||||
|
||||
|
||||
def test_oidc_settings_warn_when_cors_origins_omits_the_app_origin(caplog):
|
||||
from app.core.config import Settings
|
||||
from app.core.config import Settings, origin_of
|
||||
|
||||
base = {
|
||||
"secret_key": "x" * 32,
|
||||
@@ -401,7 +435,11 @@ def test_oidc_settings_warn_when_cors_origins_omits_the_app_origin(caplog):
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
Settings(**base, cors_origins=["http://localhost:3000"])
|
||||
assert "https://homelable.example.com" in caplog.text
|
||||
# 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()
|
||||
|
||||
@@ -706,3 +706,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,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,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()
|
||||
@@ -131,6 +131,31 @@ async def test_probe_port_skips_non_http_ports():
|
||||
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():
|
||||
ctx, factory = _patch_client(lambda req: _response("<title>X</title>"))
|
||||
|
||||
@@ -279,6 +279,38 @@ class TestIeeeCollisions:
|
||||
assert device is not None and device.ieee_address == "0xAAA"
|
||||
|
||||
|
||||
|
||||
|
||||
class TestStatusOnLink:
|
||||
"""A row a node draws is past the pending queue."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_row_is_approved_once_a_node_draws_it(self, db_session):
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", status="pending"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(db_session, node, {"ip": "10.0.0.5"})
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.status == "approved"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hidden_row_stays_hidden(self, db_session):
|
||||
"""The user hid it on purpose — drawing it does not undo that."""
|
||||
design = await _design(db_session)
|
||||
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", status="hidden"))
|
||||
node = _node(design)
|
||||
db_session.add(node)
|
||||
await db_session.commit()
|
||||
|
||||
device = await link_facts(db_session, node, {"ip": "10.0.0.5"})
|
||||
await db_session.commit()
|
||||
|
||||
assert device is not None and device.status == "hidden"
|
||||
|
||||
# --- the backfill -----------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -134,6 +134,30 @@ async def test_repoints_child_parent(db_session):
|
||||
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)
|
||||
|
||||
@@ -403,3 +403,87 @@ 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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
}
|
||||
|
||||
|
||||
@@ -273,6 +277,134 @@ 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
|
||||
@@ -509,3 +641,62 @@ async def test_persist_never_deletes(db_session) -> None:
|
||||
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"
|
||||
|
||||
@@ -93,6 +93,210 @@ class TestDesignType:
|
||||
assert res.status_code == 422
|
||||
|
||||
|
||||
class TestDeviceRackModel:
|
||||
"""The inventory row owns the front panel; the mount only places it.
|
||||
|
||||
A device wears the same faceplate, size, colour and ports in every rack, so a
|
||||
save writes them onto `device_inventory` and a load reads them back out.
|
||||
"""
|
||||
|
||||
async def _mounted(self, client: AsyncClient, headers: dict, db_session, **device) -> str:
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(InventoryDevice(id="inv-sw", ip="192.168.1.10", suggested_type="switch"))
|
||||
await db_session.commit()
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"] = [{**state["devices"][0], "device_id": "inv-sw", **device}]
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
return design_id
|
||||
|
||||
async def test_writes_the_mount_panel_onto_the_inventory_row(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
await self._mounted(client, headers, db_session, color="#ff6e00")
|
||||
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
await db_session.refresh(entry)
|
||||
assert entry.rack_faceplate_id == "switch-24"
|
||||
assert entry.rack_u_height == 1
|
||||
assert entry.rack_col_span == 12
|
||||
assert entry.rack_color == "#ff6e00"
|
||||
assert entry.rack_ports == [{"id": "p1", "label": "1", "type": "rj45", "x": 0.4, "y": 0.5}]
|
||||
|
||||
async def test_leaves_the_inventory_alone_for_an_accessory(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(InventoryDevice(id="inv-untouched", ip="192.168.1.11"))
|
||||
await db_session.commit()
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"] = [{**state["devices"][0], "faceplate_id": "shelf-1u", "ports": []}]
|
||||
await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
|
||||
entry = await db_session.get(InventoryDevice, "inv-untouched")
|
||||
await db_session.refresh(entry)
|
||||
assert entry.rack_faceplate_id is None
|
||||
|
||||
async def test_load_serves_the_inventory_panel_over_the_mount_copy(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await self._mounted(client, headers, db_session)
|
||||
# Another rack modelled the same device differently since this mount was
|
||||
# written: the row wins, ports included.
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_faceplate_id = "switch-48"
|
||||
entry.rack_color = "#a855f7"
|
||||
entry.rack_ports = [{"id": "px", "label": "uplink", "type": "sfp+", "x": 0.9, "y": 0.4}]
|
||||
await db_session.commit()
|
||||
|
||||
device = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"][0]
|
||||
assert device["faceplate_id"] == "switch-48"
|
||||
assert device["color"] == "#a855f7"
|
||||
assert [p["id"] for p in device["ports"]] == ["px"]
|
||||
|
||||
async def test_load_keeps_a_size_that_no_longer_fits_this_rack(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
# The mount sits at U 10 of a 12U rack. Grown to 4U elsewhere, it would
|
||||
# now run past the top rail — geometry is global, a rack is not.
|
||||
design_id = await self._mounted(client, headers, db_session)
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_u_height = 4
|
||||
entry.rack_col_span = 12
|
||||
await db_session.commit()
|
||||
|
||||
device = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"][0]
|
||||
assert device["u_height"] == 1
|
||||
# The plate itself cannot overflow a rail, so it still comes from the row.
|
||||
assert device["faceplate_id"] == "switch-24"
|
||||
|
||||
async def test_load_keeps_a_width_that_overruns_the_column_grid(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await self._mounted(client, headers, db_session, col_start=6, col_span=6)
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_col_span = 12
|
||||
await db_session.commit()
|
||||
|
||||
device = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"][0]
|
||||
assert device["col_span"] == 6
|
||||
|
||||
async def test_the_tray_offers_the_saved_panel(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await self._mounted(client, headers, db_session, color="#ff6e00")
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
item = next(i for i in items if i["id"] == "inv-sw")
|
||||
assert item["rack_faceplate_id"] == "switch-24"
|
||||
assert item["rack_color"] == "#ff6e00"
|
||||
assert [p["id"] for p in item["rack_ports"]] == ["p1"]
|
||||
|
||||
async def test_a_never_racked_device_reports_no_panel(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(InventoryDevice(id="inv-fresh", ip="192.168.1.12", suggested_type="nas"))
|
||||
await db_session.commit()
|
||||
|
||||
items = (
|
||||
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
|
||||
).json()["items"]
|
||||
item = next(i for i in items if i["id"] == "inv-fresh")
|
||||
assert item["rack_faceplate_id"] is None
|
||||
assert item["rack_ports"] == []
|
||||
|
||||
async def test_a_clamped_size_is_not_written_back_over_the_model(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
# The mount is served 1U because 4U no longer fits above U 10. Saving the
|
||||
# canvas then echoes that 1U back — and must not shrink the device in the
|
||||
# rack where it really is 4U.
|
||||
design_id = await self._mounted(client, headers, db_session)
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_u_height = 4
|
||||
entry.rack_col_span = 12
|
||||
await db_session.commit()
|
||||
|
||||
served = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"][0]
|
||||
assert served["u_height"] == 1
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"] = [{**state["devices"][0], "device_id": "inv-sw", "u_height": 1}]
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
await db_session.refresh(entry)
|
||||
assert entry.rack_u_height == 4
|
||||
|
||||
async def test_a_real_resize_still_reaches_the_model(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await self._mounted(client, headers, db_session)
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_u_height = 1
|
||||
await db_session.commit()
|
||||
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"] = [
|
||||
{**state["devices"][0], "device_id": "inv-sw", "u_start": 8, "u_height": 3}
|
||||
]
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
await db_session.refresh(entry)
|
||||
assert entry.rack_u_height == 3
|
||||
|
||||
async def test_load_does_not_inflate_a_mount_onto_its_neighbour(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
# A 4U model fits the rails at U 1 of a 12U rack, but another device sits
|
||||
# at U 2. Two plates drawn on the same U is not a state the canvas can
|
||||
# produce, so the load must not invent it.
|
||||
design_id = await _design(client, headers)
|
||||
db_session.add(InventoryDevice(id="inv-sw", ip="192.168.1.10", suggested_type="switch"))
|
||||
await db_session.commit()
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"] = [
|
||||
{**state["devices"][0], "device_id": "inv-sw", "u_start": 1, "u_height": 1},
|
||||
{**state["devices"][1], "u_start": 2, "u_height": 1},
|
||||
]
|
||||
assert (
|
||||
await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
).status_code == 200
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_u_height = 4
|
||||
await db_session.commit()
|
||||
|
||||
devices = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"]
|
||||
assert next(d for d in devices if d["id"] == "dev-sw")["u_height"] == 1
|
||||
|
||||
async def test_load_inflates_a_mount_that_has_the_room(
|
||||
self, client: AsyncClient, headers, db_session
|
||||
):
|
||||
design_id = await self._mounted(client, headers, db_session, u_start=1, u_height=1)
|
||||
entry = await db_session.get(InventoryDevice, "inv-sw")
|
||||
entry.rack_u_height = 4
|
||||
await db_session.commit()
|
||||
|
||||
device = (
|
||||
await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)
|
||||
).json()["devices"][0]
|
||||
assert device["u_height"] == 4
|
||||
|
||||
|
||||
|
||||
class TestSaveAndLoad:
|
||||
async def test_round_trips_the_full_state(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
@@ -108,6 +312,35 @@ class TestSaveAndLoad:
|
||||
assert loaded["cables"][0]["from_port_id"] == "p1"
|
||||
assert loaded["viewport"] == {"x": 10, "y": 20, "zoom": 1.5}
|
||||
|
||||
async def test_round_trips_the_port_visibility_override(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# When a plate draws its sockets is a per-mount display choice, so it
|
||||
# rides on the mount and not on the inventory row behind it.
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"][0]["port_visibility"] = "hover"
|
||||
state["devices"][1]["port_visibility"] = "always"
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
by_id = {d["id"]: d for d in loaded["devices"]}
|
||||
assert by_id["dev-sw"]["port_visibility"] == "hover"
|
||||
assert by_id["dev-nas"]["port_visibility"] == "always"
|
||||
|
||||
async def test_defaults_port_visibility_to_auto(self, client: AsyncClient, headers):
|
||||
# A payload from before the field existed — and any unknown value —
|
||||
# keeps the old behaviour: the faceplate decides.
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"][1]["port_visibility"] = "sometimes"
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
loaded = (await client.get(f"/api/v1/racks?design_id={design_id}", headers=headers)).json()
|
||||
assert {d["port_visibility"] for d in loaded["devices"]} == {"auto"}
|
||||
|
||||
async def test_keeps_a_mount_that_follows_its_node_check(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
@@ -802,3 +1035,35 @@ class TestDesignLifecycle:
|
||||
copied_ids = {d["id"] for d in loaded["devices"]}
|
||||
assert loaded["cables"][0]["from_device_id"] in copied_ids
|
||||
assert loaded["cables"][0]["to_device_id"] in copied_ids
|
||||
|
||||
|
||||
class TestOverlappingMounts:
|
||||
async def test_rejects_two_mounts_claiming_the_same_u(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"][1]["u_start"] = 10 # straight onto dev-sw
|
||||
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 422
|
||||
assert "overlap" in res.text
|
||||
|
||||
async def test_accepts_two_mounts_sharing_a_u_side_by_side(
|
||||
self, client: AsyncClient, headers
|
||||
):
|
||||
# Half-width plates on the same U are the whole point of the column grid.
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id, cables=[])
|
||||
state["devices"][0].update(col_start=0, col_span=6)
|
||||
state["devices"][1].update(u_start=10, u_height=1, col_start=6, col_span=6)
|
||||
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
async def test_lets_two_racks_use_the_same_u(self, client: AsyncClient, headers):
|
||||
design_id = await _design(client, headers)
|
||||
state = _state(design_id, cables=[])
|
||||
state["racks"].append({**state["racks"][0], "id": "rack-2", "name": "Second"})
|
||||
state["devices"][1].update(rack_id="rack-2", u_start=10, u_height=1)
|
||||
|
||||
res = await client.post("/api/v1/racks/save", json=state, headers=headers)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1436,3 +1437,247 @@ async def test_run_device_scan_keeps_what_it_found_when_the_budget_runs_out(mem_
|
||||
# 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"
|
||||
|
||||
@@ -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)
|
||||
@@ -255,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"
|
||||
|
||||
@@ -450,6 +450,37 @@ async def test_check_service_non_http_port_is_unknown():
|
||||
mock_http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [515, 9100, 9101, 9107])
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_never_touches_a_raw_printing_port(port):
|
||||
"""Issue #404: a GET to JetDirect/LPD makes the printer print it.
|
||||
|
||||
The status checker runs every 60 s, so a printer approved as a node used to
|
||||
print one page a minute, forever. These ports get no request at all.
|
||||
"""
|
||||
svc = {"port": port, "protocol": "tcp", "service_name": "jetdirect"}
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
||||
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_tcp.assert_not_called()
|
||||
mock_http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_printer_port_named_http_is_still_skipped():
|
||||
"""A service_name saying "http" must not talk the checker onto 9100.
|
||||
|
||||
Fingerprinting labels 9100 "Node Exporter", and an override can put any
|
||||
name there — the port decides, not the label.
|
||||
"""
|
||||
svc = {"port": 9100, "protocol": "tcp", "service_name": "http-node-exporter"}
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
||||
result = await check_service(svc, "10.0.0.1")
|
||||
assert result == "unknown"
|
||||
mock_http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_ssh_port_22_is_unknown():
|
||||
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
||||
@@ -516,7 +547,14 @@ async def _captured_url(svc, host):
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_host_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_drops_the_scanned_port_behind_a_reverse_proxy():
|
||||
"""Issue #382 — an override points at a proxy; the internal port breaks it."""
|
||||
svc = {"port": 8083, "protocol": "tcp", "service_name": "blog", "host": "https://mywebserver.mydomain.com"}
|
||||
assert await _captured_url(svc, "192.168.100.100") == "https://mywebserver.mydomain.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -538,21 +576,21 @@ async def test_check_service_takes_the_port_from_the_override():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_lets_the_service_port_beat_the_override_port():
|
||||
async def test_check_service_keeps_the_port_typed_into_the_override():
|
||||
svc = {"port": 3000, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:3000"
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8443"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_uses_the_first_host_of_a_comma_list_override():
|
||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com, alt.example.com"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_service_brackets_an_ipv6_override():
|
||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http", "host": "[2001:db8::1]:8080"}
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://[2001:db8::1]:80"
|
||||
assert await _captured_url(svc, "10.0.0.1") == "http://[2001:db8::1]:8080"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -915,3 +915,91 @@ async def test_sync_now_rejected_without_host(
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zigbee/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@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 devices reach the Device Inventory too."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
nodes = res.json()["result"]["nodes"]
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.discovery_source == "zigbee")
|
||||
)
|
||||
).scalars().all()
|
||||
by_ieee = {r.ieee_address: r for r in rows}
|
||||
assert set(by_ieee) == {"0x00000000", "0x00000001"}
|
||||
|
||||
# Each node names the row it draws, so the canvas save links to it rather
|
||||
# than minting a second, IEEE-less row.
|
||||
for node in nodes:
|
||||
assert node["device_id"] == by_ieee[node["ieee_address"]].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_import_reuses_the_existing_inventory_row(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
existing = InventoryDevice(
|
||||
ieee_address="0x00000001",
|
||||
friendly_name="router_1",
|
||||
status="pending",
|
||||
discovery_source="zigbee",
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
existing_id = existing.id
|
||||
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
router = next(
|
||||
n for n in res.json()["result"]["nodes"] if n["ieee_address"] == "0x00000001"
|
||||
)
|
||||
assert router["device_id"] == existing_id
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.ieee_address == "0x00000001")
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canvas_import_failure_is_reported_on_the_job(
|
||||
client: AsyncClient, headers: dict
|
||||
) -> None:
|
||||
"""A failed inventory write fails the job rather than returning half a map."""
|
||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch, patch(
|
||||
"app.api.routes.zigbee._persist_pending_import",
|
||||
new=AsyncMock(side_effect=ValueError("inventory write failed")),
|
||||
):
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
job_id = await _start_canvas_import(
|
||||
client, headers, {"mqtt_host": "localhost", "mqtt_port": 1883}
|
||||
)
|
||||
|
||||
res = await client.get(f"/api/v1/zigbee/import/{job_id}", headers=headers)
|
||||
assert res.status_code == 422
|
||||
assert "inventory write failed" in res.json()["detail"]
|
||||
|
||||
@@ -601,3 +601,79 @@ async def test_sync_now_rejected_without_host(
|
||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
||||
res = await client.post("/api/v1/zwave/sync-now")
|
||||
assert res.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_also_lands_in_device_inventory(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
"""A canvas import is an import: the devices reach the Device Inventory too."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
rows = (
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(InventoryDevice.discovery_source == "zwave")
|
||||
)
|
||||
).scalars().all()
|
||||
by_ieee = {r.ieee_address: r for r in rows}
|
||||
assert set(by_ieee) == {"zwave-0xh-1", "zwave-0xh-2"}
|
||||
|
||||
# Every returned node names the row it draws, so the canvas save links to it
|
||||
# rather than minting a second one.
|
||||
for node in res.json()["nodes"]:
|
||||
assert node["device_id"] == by_ieee[node["ieee_address"]].id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_reuses_the_existing_inventory_row(
|
||||
client: AsyncClient, headers: dict, db_session
|
||||
) -> None:
|
||||
"""A device already in the inventory is pointed at, not duplicated."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.models import InventoryDevice
|
||||
|
||||
existing = InventoryDevice(
|
||||
ieee_address="zwave-0xh-1",
|
||||
friendly_name="Controller",
|
||||
status="pending",
|
||||
discovery_source="zwave",
|
||||
)
|
||||
db_session.add(existing)
|
||||
await db_session.commit()
|
||||
existing_id = existing.id
|
||||
|
||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
||||
res = await client.post(
|
||||
"/api/v1/zwave/import",
|
||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
|
||||
count = len(
|
||||
(
|
||||
await db_session.execute(
|
||||
select(InventoryDevice).where(
|
||||
InventoryDevice.ieee_address == "zwave-0xh-1"
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
assert count == 1
|
||||
coordinator = next(
|
||||
n for n in res.json()["nodes"] if n["ieee_address"] == "zwave-0xh-1"
|
||||
)
|
||||
assert coordinator["device_id"] == existing_id
|
||||
|
||||
@@ -13,6 +13,12 @@ services:
|
||||
- homelable
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
# MAC addresses: on this bridge network the scanner can never see them —
|
||||
# ARP is layer 2 and every LAN host is one hop away behind the Docker
|
||||
# gateway. To collect MACs (and so keep DHCP devices matched across an IP
|
||||
# change), put the backend on the LAN: comment out the `networks:` key
|
||||
# above and uncomment the line below (Linux hosts only).
|
||||
# network_mode: host
|
||||
|
||||
frontend:
|
||||
image: ghcr.io/pouzor/homelable-frontend:latest
|
||||
|
||||
@@ -16,6 +16,12 @@ services:
|
||||
# Required for ping-based status checks
|
||||
cap_add:
|
||||
- NET_RAW
|
||||
# MAC addresses: on this bridge network the scanner can never see them —
|
||||
# ARP is layer 2 and every LAN host is one hop away behind the Docker
|
||||
# gateway. To collect MACs (and so keep DHCP devices matched across an IP
|
||||
# change), put the backend on the LAN: comment out the `networks:` key
|
||||
# above and uncomment the line below (Linux hosts only).
|
||||
# network_mode: host
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/health"]
|
||||
interval: 10s
|
||||
@@ -43,6 +49,10 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.frontend
|
||||
args:
|
||||
# Serve Homelable under a subpath, e.g. VITE_BASE_PATH=/homelab/ in .env.
|
||||
# Defaults to the root — see docs/INSTALLATION.md.
|
||||
VITE_BASE_PATH: ${VITE_BASE_PATH:-/}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Standalone (no backend) Homelable served under a subpath (__BASE__).
|
||||
# Generated at image build time — see docker/nginx.subpath.conf.template.
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Relative Location headers — the port and scheme belong to whatever proxy is
|
||||
# in front, not to this server block.
|
||||
absolute_redirect off;
|
||||
|
||||
location = __BASE_NO_SLASH__ {
|
||||
return 301 __BASE__;
|
||||
}
|
||||
|
||||
# SPA fallback under the prefix — no backend proxy
|
||||
location __BASE__ {
|
||||
try_files $uri $uri/ __BASE__index.html;
|
||||
}
|
||||
|
||||
# Same, for a front proxy that strips the prefix before forwarding
|
||||
location / {
|
||||
root /usr/share/nginx/html__BASE_NO_SLASH__;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
# Homelable served under a subpath (__BASE__) instead of the root of the origin.
|
||||
#
|
||||
# Generated at image build time by Dockerfile.frontend when VITE_BASE_PATH is set:
|
||||
# __BASE__ is the prefix with its trailing slash, __BASE_NO_SLASH__ without it. A
|
||||
# default build keeps docker/nginx.conf and this file is never read.
|
||||
#
|
||||
# The static build is copied to /usr/share/nginx/html__BASE__, so every block below
|
||||
# uses plain `root` semantics — `alias` plus `try_files` mis-resolves $uri.
|
||||
#
|
||||
# Both reverse-proxy styles work against this config:
|
||||
# - prefix forwarded intact (proxy_pass http://homelable;) -> the __BASE__ blocks
|
||||
# - prefix stripped (proxy_pass http://homelable/;) -> the trailing blocks
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
# Relative Location headers — the port and scheme belong to whatever proxy is
|
||||
# in front, not to this server block.
|
||||
absolute_redirect off;
|
||||
|
||||
# --- prefix forwarded intact ------------------------------------------------
|
||||
location = __BASE_NO_SLASH__ {
|
||||
return 301 __BASE__;
|
||||
}
|
||||
|
||||
# WebSocket (must come before the API block to take priority)
|
||||
location __BASE__api/v1/status/ws/ {
|
||||
proxy_pass http://backend:8000/api/v1/status/ws/;
|
||||
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 __BASE__api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Legacy /ws/ path
|
||||
location __BASE__ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# SPA fallback under the prefix
|
||||
location __BASE__ {
|
||||
try_files $uri $uri/ __BASE__index.html;
|
||||
}
|
||||
|
||||
# --- prefix already stripped by the front proxy -----------------------------
|
||||
# No redirect to __BASE__ here: the front proxy would strip it again and loop.
|
||||
location /api/v1/status/ws/ {
|
||||
proxy_pass http://backend: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://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html__BASE_NO_SLASH__;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
# Documentation
|
||||
|
||||
A markdown document per device, plus a free-form Library of pages and folders,
|
||||
behind the **Documentation** entry in the left sidebar.
|
||||
|
||||
Before this, the only writing surface in Homelable was `device_inventory.notes`
|
||||
— one `TEXT` column rendered as plain text. This replaces it with something that
|
||||
can be structured, linked, searched and kept honest, without ever throwing the
|
||||
old notes away.
|
||||
|
||||
---
|
||||
|
||||
## The model
|
||||
|
||||
Three tables. See `docs/database-model.md` for the columns.
|
||||
|
||||
| Table | What |
|
||||
|---|---|
|
||||
| `documents` | One markdown document. Either a Library `page`/`folder`, or a `device` / `node` / `design` document describing exactly one thing. |
|
||||
| `document_revisions` | Prior bodies, pruned to the most recent 50 per document. |
|
||||
| `documents_fts` | The FTS5 search index. Optional — see *Search* below. |
|
||||
|
||||
**A document attaches to the device, not to the node.** Since 3.3.0 a node draws
|
||||
a device and the facts live on `device_inventory`, so one device reads the same
|
||||
on every canvas it appears on.
|
||||
|
||||
**Every link is `SET NULL` and the title is denormalized.** Deleting a device or
|
||||
a canvas never destroys what was written: the document survives as an orphan and
|
||||
can be re-linked or filed into the Library. Foreign keys are off in this SQLite
|
||||
setup, so the delete paths clear the columns by hand through
|
||||
`app/services/doc_links.py::unlink_documents` — `nodes`, `scan` and `designs`
|
||||
each call it before they delete.
|
||||
|
||||
**A folder is a document** (`kind='folder'`). That is what allows an empty
|
||||
folder, and lets a folder carry an index body.
|
||||
|
||||
**One document per target.** Enforced twice: a 409 in the route, and partial
|
||||
unique indexes created in `init_db` (`DOCUMENT_DDL` in `db/database.py` —
|
||||
`create_all` cannot express a partial index or a virtual table).
|
||||
|
||||
---
|
||||
|
||||
## The generated header
|
||||
|
||||
`app/services/doc_template.py` scaffolds a device document from the live facts:
|
||||
addresses, hardware, one section per fingerprinted service, rack and zone
|
||||
placement, canvas neighbours, custom properties. Old `notes` are appended
|
||||
verbatim under `## Notes` behind an HTML-comment provenance marker.
|
||||
|
||||
**It is generated once and then belongs to the user.** Nothing rewrites a body
|
||||
that already exists. Two things keep it from going stale silently:
|
||||
|
||||
- `facts_snapshot` records the facts as they read at generation time, so the UI
|
||||
can say *the device has changed* and list what.
|
||||
- `GET /api/v1/documents/blocks?block=…&device_id=…` renders any single block on
|
||||
demand — that is what the editor's `/device`, `/services`, `/rack`… commands
|
||||
insert. Regeneration is always one keystroke away, and never automatic.
|
||||
|
||||
A section whose data is empty is still emitted, with an italic prompt: the
|
||||
document is a checklist of what has not been written yet. Sections that are
|
||||
structural rather than descriptive — the rack position of an unracked device —
|
||||
are dropped instead.
|
||||
|
||||
Service URLs in a document follow `frontend/src/utils/serviceUrl.ts`, including
|
||||
its non-HTTP port denylist, so a link in a document is the link the canvas
|
||||
offers.
|
||||
|
||||
---
|
||||
|
||||
## The tree
|
||||
|
||||
Two roots, with different natures.
|
||||
|
||||
**Devices** is a pivot, rebuilt client-side in
|
||||
`frontend/src/documentation/tree.ts` from data the app already holds. Grouping
|
||||
by zone, group, type, physical/virtual, subnet, canvas, rack, vendor, discovery
|
||||
source, status, tag, or flat A–Z is instant and needs no server round trip.
|
||||
|
||||
- Zone and group come from walking `parent_id` up to the nearest `groupRect` /
|
||||
`group` node.
|
||||
- Subnet prefers a configured scanner range, else the address's own /24. A
|
||||
device with several IPs appears under each subnet it reaches.
|
||||
- Physical / Virtual / Host is derived: `vm`/`lxc`/`docker_container` are
|
||||
virtual, `proxmox`/`docker_host` and anything drawn as a container are hosts.
|
||||
- Canvas answers "on the loaded canvas or not" — the app only holds one at a
|
||||
time.
|
||||
|
||||
**Library** is the real folder tree, ordered by `sort_order` then title.
|
||||
|
||||
Badges: `·` no document, `○` only the generated header, `●` written, `⚠` the
|
||||
device changed since, `⏰` past its `review_every`.
|
||||
|
||||
---
|
||||
|
||||
## Editing
|
||||
|
||||
Markdown source on the left, live preview on the right. Source rather than a
|
||||
rich editor on purpose: the body is exported verbatim, so what is typed is what
|
||||
the file holds.
|
||||
|
||||
**Saving is explicit** — Ctrl/Cmd+S or the Save button — matching the canvas
|
||||
rule that nothing persists on a timer. An unsaved body is mirrored to
|
||||
`localStorage` under `homelable_docdraft:<id>` on every keystroke and cleared on
|
||||
save. On reopening, a draft taken against the version being opened is *offered*;
|
||||
a draft taken against an older version is discarded, because replaying it would
|
||||
revert a change made elsewhere.
|
||||
|
||||
`/` at the start of a line opens the insert menu: the generated blocks above,
|
||||
plus table, checklist, callout, wiki-link and date.
|
||||
|
||||
`[[` opens the link picker, anywhere in the line: filter by title, pick, and the
|
||||
finished link is written for you. It offers documents only — a device with no
|
||||
document is not a link target yet — and it addresses a document by title, or by
|
||||
`[[doc:<id>]]` when another document shares that title, since a bare link
|
||||
resolves by title and an ambiguous one would silently pick the first. The second
|
||||
bracket is held back while the menu is open (opening it moves focus, and a
|
||||
keystroke landing mid-move belongs to neither box) and given back if you cancel,
|
||||
so what you typed survives either way.
|
||||
|
||||
---
|
||||
|
||||
## Links between documents
|
||||
|
||||
`[[device:nas-01]]`, `[[doc:vlan-plan]]`, `[[node:<id>]]`, or a bare
|
||||
`[[VLAN plan]]`; `[[…|label]]` sets the text. A device resolves by id or label,
|
||||
a document by id, slug, then title, all case-insensitively. An unresolved link
|
||||
renders red and offers to create the document.
|
||||
|
||||
Parsed as a text pass over the rendered children (`wikilinks.ts` +
|
||||
`markdown/WikiText.tsx`) rather than as a remark plugin, so the file stays
|
||||
ordinary markdown for anything else that reads it.
|
||||
|
||||
**Raw HTML is deliberately not rendered** — `rehype-raw` is absent — so a
|
||||
document cannot inject markup and no sanitiser is needed.
|
||||
|
||||
### Backlinks
|
||||
|
||||
Every document lists what points at it, under **Linked from**, with the line the
|
||||
link was written on and the label it was written as.
|
||||
|
||||
The inversion is done **server-side** (`services/doc_backlinks.py`,
|
||||
`GET /documents/{id}/backlinks`), because the browser holds no bodies but the
|
||||
open one: `GET /documents` is metadata-only so the tree can badge without
|
||||
downloading the space. That service mirrors the resolution rules in
|
||||
`wikilinks.ts` — same prefixes, same id → slug → title fallback, same
|
||||
case-insensitivity — the way the generator's `service_url` mirrors
|
||||
`utils/serviceUrl.ts`. Change one and change the other;
|
||||
`test_doc_backlinks.py` pins the rules.
|
||||
|
||||
Repeated links from the same document collapse into one entry with a count, and
|
||||
a document never backlinks itself. The inventory is only loaded when some body
|
||||
actually carries a `[[device:…]]`.
|
||||
|
||||
---
|
||||
|
||||
## History
|
||||
|
||||
Every explicit save that changes the body snapshots the previous one, capped at
|
||||
`REVISION_LIMIT` (50) per document; so do restore, regenerate, scaffold and the
|
||||
notes migration, each recording why.
|
||||
|
||||
The clock-arrow button in the header opens the history rail: what each version
|
||||
was (Saved, Regenerated, Restored, Migrated from notes…), when, and how big.
|
||||
Selecting one replaces the body with that version — same title, same chips, same
|
||||
rail — and **Changes** turns it into a line diff against the current body, with
|
||||
long unchanged runs collapsed. **Restore** brings it back, snapshotting the body
|
||||
it replaces first, so a restore is itself undoable.
|
||||
|
||||
A revision's body is fetched only when it is opened: the list carries a size, not
|
||||
the text, so fifty versions cost one small request.
|
||||
|
||||
The diff is a plain LCS over lines (`documentation/diff.ts`), after the common
|
||||
head and tail are trimmed — no diff library, and a pathological pair of long,
|
||||
wholly different bodies degrades to "replaced wholesale" instead of building a
|
||||
250k-cell matrix.
|
||||
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
`GET /api/v1/documents/search?q=` over title, tags and body.
|
||||
|
||||
The same index answers the canvas' own search modal (Ctrl/Cmd+K), which lists
|
||||
matching documents under the nodes and pending devices; picking one switches to
|
||||
Documentation and opens it. The request is debounced and skipped below two
|
||||
characters, and a failure there costs only the document rows — the node and
|
||||
device halves filter lists already in memory.
|
||||
|
||||
FTS5 is **not** assumed: the SQLite shipped in the LXC and Docker images may be
|
||||
built without it, the same reason `database.py` avoids JSON1. `doc_search.py`
|
||||
probes once and falls back to `LIKE`, and the response says which engine
|
||||
answered (`fts5` or `like`) so the UI can drop snippet highlighting. The index is
|
||||
maintained from Python on every write — no triggers — and `_reindex_documents_fts`
|
||||
catches up on boot after an upgrade or a restored backup.
|
||||
|
||||
User input is never FTS5 syntax: `build_match_query` quotes every term, so an
|
||||
IP or a stray quote is data.
|
||||
|
||||
---
|
||||
|
||||
## Migrating the old notes
|
||||
|
||||
Non-destructive and repeatable.
|
||||
|
||||
1. `GET /api/v1/documents/coverage` reports `notes_unmigrated`.
|
||||
2. A banner offers to migrate.
|
||||
3. `POST /api/v1/documents/scaffold {"only_with_notes": true}` creates one
|
||||
document per device, old notes appended verbatim, with a `migrate` revision.
|
||||
4. **`device_inventory.notes` is left exactly as it was.** It is still the
|
||||
column the HACS integration, the YAML export, the MCP tools and the canvas
|
||||
search read. Deprecating it is a separate decision.
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path |
|
||||
|---|---|
|
||||
| `GET` | `/api/v1/documents` — metadata only, filterable by `kind`, `parent_id`, `device_id`, `tag` |
|
||||
| `GET` | `/api/v1/documents/{id}` |
|
||||
| `POST` | `/api/v1/documents` |
|
||||
| `PATCH` | `/api/v1/documents/{id}` |
|
||||
| `DELETE` | `/api/v1/documents/{id}` — a folder takes its subtree |
|
||||
| `GET` | `/api/v1/documents/{id}/revisions`, `/revisions/{rev_id}` |
|
||||
| `GET` | `/api/v1/documents/{id}/backlinks` — the documents linking here |
|
||||
| `POST` | `/api/v1/documents/{id}/revisions/{rev_id}/restore` |
|
||||
| `POST` | `/api/v1/documents/{id}/regenerate` — erase the body and scaffold it again |
|
||||
| `GET` | `/api/v1/documents/search?q=&limit=` |
|
||||
| `GET` | `/api/v1/documents/blocks?block=&device_id=` |
|
||||
| `GET` | `/api/v1/documents/coverage` |
|
||||
| `POST` | `/api/v1/documents/scaffold` |
|
||||
|
||||
---
|
||||
|
||||
## Standalone mode
|
||||
|
||||
Documents are **full-mode only**. Standalone has no backend to store or search
|
||||
them, and the same reasoning as ADR-001 applies: the section is hidden in the
|
||||
sidebar and explains itself if reached directly, rather than being polyfilled
|
||||
into `localStorage` with no search and no history.
|
||||
|
||||
---
|
||||
|
||||
## Not built yet
|
||||
|
||||
Export/import of the tree as `.md` files, a print/handbook view, an aggregated
|
||||
open-tasks view, image upload inside a document (would reuse
|
||||
`api/routes/media.py`, full-mode only), and the MCP tools — those are the
|
||||
planned second lot.
|
||||
+2
-1
@@ -236,7 +236,8 @@ diagram's online/offline tally. **PNG export** works here too.
|
||||
- No power draw or outlet budgeting.
|
||||
- No undo/redo on a rack canvas (the diagram canvas keeps its own).
|
||||
- Rack canvases are not shown by the read-only [Live View](../README.md#live-view-read-only-public-canvas).
|
||||
- The MCP server exposes diagram nodes and links only, not racks.
|
||||
- The MCP server can read and edit a rack canvas, but its save is last-writer-wins:
|
||||
an AI write while you have the canvas open with unsaved changes loses one side.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Generated
+1577
-66
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "3.3.5",
|
||||
"version": "3.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -31,6 +31,9 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<defs>
|
||||
<radialGradient id="bg-glow" cx="50%" cy="35%" r="55%">
|
||||
<stop offset="0%" stop-color="#00d4ff" stop-opacity="0.08"/>
|
||||
<stop offset="100%" stop-color="#0d1117" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="node-glow">
|
||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background -->
|
||||
<circle cx="32" cy="32" r="32" fill="#0d1117"/>
|
||||
<circle cx="32" cy="32" r="32" fill="url(#bg-glow)"/>
|
||||
|
||||
<!-- House body -->
|
||||
<path d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
|
||||
fill="#161b22" stroke="#00d4ff" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Floor line (subtle) -->
|
||||
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" stroke-width="0.5" opacity="0.25"/>
|
||||
|
||||
<!-- Door -->
|
||||
<rect x="27" y="40" width="10" height="13" rx="1.5"
|
||||
fill="#0d1117" stroke="#00d4ff" stroke-width="1" opacity="0.9"/>
|
||||
|
||||
<!-- Network lines (drawn under nodes) -->
|
||||
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" stroke-width="1.2" opacity="0.7" stroke-linecap="round"/>
|
||||
|
||||
<!-- Center hub glow -->
|
||||
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12"/>
|
||||
<!-- Center hub -->
|
||||
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Left node -->
|
||||
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Right node -->
|
||||
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter="url(#node-glow)"/>
|
||||
|
||||
<!-- Top node -->
|
||||
<circle cx="32" cy="23" r="2" fill="#a855f7" filter="url(#node-glow)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
+168
-25
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useCallback, useMemo, useRef, useState, lazy, Suspense } from 'react'
|
||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||
import { type Node } from '@xyflow/react'
|
||||
import { applyDagreLayout } from '@/utils/layout'
|
||||
@@ -9,6 +9,8 @@ import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||
import { copyToClipboard } from '@/utils/clipboard'
|
||||
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
|
||||
import { useDocsUrlSync } from '@/hooks/useDocsUrlSync'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { ExportModal } from '@/components/modals/ExportModal'
|
||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||
@@ -53,24 +55,35 @@ import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
|
||||
import { DEMO_SCAN_RUNS, DEMO_INVENTORY_DEVICES } from '@/walkthrough/demoTourData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import { bootstrapAuth } from '@/auth/bootstrap'
|
||||
// Code-split: the Documentation section pulls in the markdown renderer, which a
|
||||
// user who never leaves the canvas should not pay for on first load.
|
||||
const DocumentationView = lazy(async () => ({
|
||||
default: (await import('@/documentation/components/DocumentationView')).DocumentationView,
|
||||
}))
|
||||
import { useDocsStore } from '@/documentation/store'
|
||||
import { useUiStore } from '@/stores/uiStore'
|
||||
import { RackCanvas } from '@/rack/components/RackCanvas'
|
||||
import { RackCablePanel } from '@/rack/components/RackCablePanel'
|
||||
import { useRackStore } from '@/rack/store'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, DesignType, FloorMapConfig, NodeType } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
|
||||
import type { ProxmoxNode, ProxmoxEdge, ProxmoxCanvasMode } from '@/components/proxmox/types'
|
||||
import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||
import { groupProxmoxGuests, layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
export default function App() {
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, addToZone, importZoneSubnet, floorMap, setFloorMap } = useCanvasStore()
|
||||
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addNodesToGroup, addNodesToContainer, addNodesToZone, importZoneSubnet, floorMap, setFloorMap } = useCanvasStore()
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
const { isAuthenticated, isInitialized } = useAuthStore()
|
||||
const authBootstrapStarted = useRef(false)
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
const { activeDesignId, activeDesignType, setDesigns, setActiveDesign } = useDesignStore()
|
||||
const appView = useUiStore((s) => s.view)
|
||||
const setAppView = useUiStore((s) => s.setView)
|
||||
const openDocId = useDocsStore((s) => s.openDoc?.id ?? null)
|
||||
const isRackDesign = activeDesignType === 'rack'
|
||||
const rackDirty = useRackStore((s) => s.hasUnsavedChanges)
|
||||
const rackEditSeq = useRackStore((s) => s.editSeq)
|
||||
@@ -132,9 +145,14 @@ export default function App() {
|
||||
const [addTextOpen, setAddTextOpen] = useState(false)
|
||||
const [editNodeId, setEditNodeId] = useState<string | null>(null)
|
||||
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
|
||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
|
||||
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
|
||||
const [pendingZoneAdd, setPendingZoneAdd] = useState<{ nodeId: string; zoneId: string } | null>(null)
|
||||
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeIds: string[]; groupId: string } | null>(null)
|
||||
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeIds: string[]; containerId: string } | null>(null)
|
||||
const [pendingZoneAdd, setPendingZoneAdd] = useState<{ nodeIds: string[]; zoneId: string } | null>(null)
|
||||
// Labels for the add-to-group/container/zone confirmation, in the order dropped.
|
||||
const labelsOf = useCallback(
|
||||
(ids: string[]) => ids.map((id) => nodes.find((n) => n.id === id)?.data.label ?? ''),
|
||||
[nodes],
|
||||
)
|
||||
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
|
||||
const [scanConfigOpen, setScanConfigOpen] = useState(false)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
@@ -471,6 +489,35 @@ export default function App() {
|
||||
if (activeDesignId) setDesignIdInUrl(activeDesignId)
|
||||
}, [activeDesignId])
|
||||
|
||||
// A document is linkable in the same way: `?view=docs&doc=<id>` — written on
|
||||
// every change, and read back on boot so a refresh reopens it.
|
||||
const restoreDocsUrl = useCallback(
|
||||
async (docId: string | null) => {
|
||||
setAppView('documentation')
|
||||
if (docId) await useDocsStore.getState().open(docId)
|
||||
},
|
||||
[setAppView],
|
||||
)
|
||||
useDocsUrlSync({
|
||||
view: appView,
|
||||
openDocId,
|
||||
ready: isAuthenticated,
|
||||
enabled: !STANDALONE,
|
||||
onRestore: restoreDocsUrl,
|
||||
})
|
||||
|
||||
// The canvas' way into Documentation: it knows a device id, and the section
|
||||
// resolves that to a document — writing one from the device's facts when
|
||||
// there is none, exactly as picking the device in its tree would.
|
||||
const openDocumentationFor = useCallback(
|
||||
async (deviceId: string, label: string) => {
|
||||
setAppView('documentation')
|
||||
const ok = await useDocsStore.getState().openForDevice(deviceId, label)
|
||||
if (!ok) toast.error('Could not open the documentation for that device')
|
||||
},
|
||||
[setAppView],
|
||||
)
|
||||
|
||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||
const undoRef = useRef(undo)
|
||||
const redoRef = useRef(redo)
|
||||
@@ -486,8 +533,19 @@ export default function App() {
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable
|
||||
|
||||
if (ctrl && e.key === 's') { e.preventDefault(); handleSaveRef.current(); return }
|
||||
if (ctrl && e.key === 'z') { e.preventDefault(); undoRef.current(); return }
|
||||
if (ctrl && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redoRef.current(); return }
|
||||
// Undo belongs to whatever is being typed in — the document editor keeps
|
||||
// its own history, a plain field has the browser's — and the canvas only
|
||||
// gets it when nothing has the caret.
|
||||
if (ctrl && e.key.toLowerCase() === 'z') {
|
||||
if (isInput) return
|
||||
e.preventDefault()
|
||||
// Shift+Z is the other redo, and matched neither branch while both
|
||||
// compared a lowercase key — a shifted `z` arrives as `Z`.
|
||||
if (e.shiftKey) redoRef.current()
|
||||
else undoRef.current()
|
||||
return
|
||||
}
|
||||
if (ctrl && e.key.toLowerCase() === 'y') { if (isInput) return; e.preventDefault(); redoRef.current(); return }
|
||||
if (ctrl && e.key === 'k') { e.preventDefault(); setSearchOpen(true); return }
|
||||
// Copy/paste (Ctrl/Cmd+C/V) handled in CanvasContainer so paste can place
|
||||
// nodes under the cursor / viewport center.
|
||||
@@ -556,6 +614,7 @@ export default function App() {
|
||||
position: getCenteredPosition(360, 240),
|
||||
data: {
|
||||
label: data.label,
|
||||
description: data.description,
|
||||
type: 'groupRect',
|
||||
status: 'unknown',
|
||||
services: [],
|
||||
@@ -590,6 +649,7 @@ export default function App() {
|
||||
const existing = nodes.find((n) => n.id === editingGroupRectId)
|
||||
updateNode(editingGroupRectId, {
|
||||
label: data.label,
|
||||
description: data.description,
|
||||
custom_colors: {
|
||||
...existing?.data.custom_colors,
|
||||
border: data.border_color,
|
||||
@@ -766,7 +826,9 @@ export default function App() {
|
||||
if (STANDALONE) {
|
||||
// Standalone reads canvas from localStorage; pass the active design id so
|
||||
// the read-only tab renders the same canvas the user is viewing.
|
||||
const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
|
||||
const url = activeDesignId
|
||||
? withBase(`view?design=${encodeURIComponent(activeDesignId)}`)
|
||||
: withBase('view')
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
@@ -778,7 +840,7 @@ export default function App() {
|
||||
}
|
||||
const params = new URLSearchParams({ key: res.data.key })
|
||||
if (activeDesignId) params.set('design', activeDesignId)
|
||||
window.open(`/view?${params.toString()}`, '_blank', 'noopener,noreferrer')
|
||||
window.open(withBase(`view?${params.toString()}`), '_blank', 'noopener,noreferrer')
|
||||
} catch {
|
||||
toast.error('Failed to open live view')
|
||||
}
|
||||
@@ -813,6 +875,9 @@ export default function App() {
|
||||
type: zn.type as NodeData['type'],
|
||||
status: 'unknown' as const,
|
||||
services: [],
|
||||
// The import already upserted the Device Inventory row; carry its id so
|
||||
// the canvas save links to that row instead of minting a second one.
|
||||
...(zn.device_id ? { device_id: zn.device_id } : {}),
|
||||
...(zn.lqi != null ? { properties: [{ key: 'LQI', value: String(zn.lqi), icon: 'signal', visible: true }] } : {}),
|
||||
...(zn.model ? { os: zn.model } : {}),
|
||||
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||
@@ -863,6 +928,8 @@ export default function App() {
|
||||
type: zn.type as NodeData['type'],
|
||||
status: 'unknown' as const,
|
||||
services: [],
|
||||
// Same as the Zigbee import: the row exists already, point at it.
|
||||
...(zn.device_id ? { device_id: zn.device_id } : {}),
|
||||
...(zn.model ? { os: zn.model } : {}),
|
||||
...(zn.parent_id ? { parent_id: zn.parent_id } : {}),
|
||||
},
|
||||
@@ -888,42 +955,84 @@ export default function App() {
|
||||
markUnsaved()
|
||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||
|
||||
const handleProxmoxAddToCanvas = useCallback((pmNodes: ProxmoxNode[], pmEdges: ProxmoxEdge[]) => {
|
||||
const handleProxmoxAddToCanvas = useCallback((
|
||||
pmNodes: ProxmoxNode[],
|
||||
pmEdges: ProxmoxEdge[],
|
||||
mode: ProxmoxCanvasMode = 'linked',
|
||||
) => {
|
||||
snapshotHistory()
|
||||
const COLS = 4
|
||||
const SPACING_X = 190
|
||||
const SPACING_Y = 110
|
||||
const cols = Math.min(COLS, pmNodes.length)
|
||||
const rows = Math.ceil(pmNodes.length / COLS)
|
||||
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||
// Multiple hosts from one import = a cluster → chain them via left/right
|
||||
// 'cluster' edges. Those endpoints need one left + one right handle each
|
||||
// (both default to 0), so grant them to the host nodes up front.
|
||||
const clusterEdges = buildProxmoxClusterEdges(pmNodes)
|
||||
const cluster = clusterEdges.length > 0
|
||||
pmNodes.forEach((pn, i) => {
|
||||
const col = i % COLS
|
||||
const row = Math.floor(i / COLS)
|
||||
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
|
||||
|
||||
// Container mode: each host becomes a box and its guests are nested inside
|
||||
// it. Anything the import gives no host for stays a loose node below.
|
||||
const { hostIds, childrenByHost, hostOfChild, looseIds } = groupProxmoxGuests(pmNodes, pmEdges)
|
||||
const hostIdSet = new Set(hostIds)
|
||||
|
||||
let placement: Record<string, { x: number; y: number }>
|
||||
let hostSizes: Record<string, { width: number; height: number }> = {}
|
||||
if (mode === 'container') {
|
||||
const measured = measureProxmoxContainers(hostIds, childrenByHost, looseIds.length)
|
||||
const origin = getCenteredPosition(measured.width, measured.height)
|
||||
const laid = layoutProxmoxContainers(hostIds, childrenByHost, looseIds, origin)
|
||||
placement = laid.positions
|
||||
hostSizes = laid.hostSizes
|
||||
} else {
|
||||
const cols = Math.min(COLS, pmNodes.length)
|
||||
const rows = Math.ceil(pmNodes.length / COLS)
|
||||
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||
placement = {}
|
||||
pmNodes.forEach((pn, i) => {
|
||||
placement[pn.id] = {
|
||||
x: origin.x + (i % COLS) * SPACING_X,
|
||||
y: origin.y + Math.floor(i / COLS) * SPACING_Y,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Hosts first: addNode resolves `data.parent_id` against the nodes already
|
||||
// in the store, so a guest added before its container would not nest.
|
||||
const ordered = mode === 'container'
|
||||
? [...pmNodes.filter((n) => hostIdSet.has(n.id)), ...pmNodes.filter((n) => !hostIdSet.has(n.id))]
|
||||
: pmNodes
|
||||
ordered.forEach((pn) => {
|
||||
const position = placement[pn.id] ?? getCenteredPosition()
|
||||
const isClusterHost = cluster && pn.type === 'proxmox'
|
||||
const isContainerHost = mode === 'container' && hostIdSet.has(pn.id)
|
||||
const parentId = mode === 'container' ? hostOfChild[pn.id] : undefined
|
||||
const size = hostSizes[pn.id]
|
||||
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||
id: pn.id,
|
||||
type: pn.type,
|
||||
position,
|
||||
...(isContainerHost && size ? { width: size.width, height: size.height } : {}),
|
||||
data: {
|
||||
label: pn.label,
|
||||
type: pn.type as NodeData['type'],
|
||||
status: (pn.status === 'online' ? 'online' : 'unknown') as NodeData['status'],
|
||||
services: [],
|
||||
// Same as the Zigbee import: the row exists already, point at it.
|
||||
...(pn.device_id ? { device_id: pn.device_id } : {}),
|
||||
...(pn.ip ? { ip: pn.ip } : {}),
|
||||
...(pn.hostname ? { hostname: pn.hostname } : {}),
|
||||
...(isClusterHost ? { left_handles: 1, right_handles: 1 } : {}),
|
||||
...(isContainerHost ? { container_mode: true } : {}),
|
||||
...(parentId ? { parent_id: parentId } : {}),
|
||||
},
|
||||
}
|
||||
addNode(newNode)
|
||||
})
|
||||
// Host → guest links render as 'virtual' edges (VM/LXC ↔ host).
|
||||
// Host → guest links render as 'virtual' edges (VM/LXC ↔ host). A nested
|
||||
// guest gets none: sitting inside the host already says it runs there, and
|
||||
// the edge just loops out of the box and back (#399 follow-up).
|
||||
pmEdges.forEach((pe) => {
|
||||
if (hostOfChild[pe.target] === pe.source && mode === 'container') return
|
||||
onConnect({
|
||||
source: pe.source,
|
||||
sourceHandle: 'bottom',
|
||||
@@ -1058,6 +1167,14 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{appView === 'documentation' ? (
|
||||
<Suspense
|
||||
fallback={<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">Loading documentation…</div>}
|
||||
>
|
||||
<DocumentationView />
|
||||
</Suspense>
|
||||
) : (
|
||||
<>
|
||||
<Toolbar
|
||||
onSave={handleSave}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
@@ -1099,9 +1216,16 @@ export default function App() {
|
||||
// Standalone has no Device Inventory to open (ADR-001 style
|
||||
// gate: the whole scan surface is backend-only).
|
||||
onOpenInventory={STANDALONE ? undefined : (deviceId) => openInventoryModal(deviceId)}
|
||||
onOpenDocumentation={
|
||||
STANDALONE
|
||||
? undefined
|
||||
: (deviceId, label) => void openDocumentationFor(deviceId, label)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1239,6 +1363,9 @@ export default function App() {
|
||||
const rc = n.data.custom_colors ?? {}
|
||||
return {
|
||||
label: n.data.label,
|
||||
// `notes` is where a zone description used to try to live; read it
|
||||
// as a fallback so a canvas loaded before the fix still shows it.
|
||||
description: n.data.description ?? n.data.notes ?? '',
|
||||
font: rc.font ?? 'inter',
|
||||
text_color: rc.text_color ?? '#e6edf3',
|
||||
text_position: rc.text_position ?? 'top-left',
|
||||
@@ -1305,15 +1432,23 @@ export default function App() {
|
||||
open={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
|
||||
onOpenDocument={
|
||||
STANDALONE
|
||||
? undefined
|
||||
: (docId) => {
|
||||
setAppView('documentation')
|
||||
void useDocsStore.getState().open(docId)
|
||||
}
|
||||
}
|
||||
/>
|
||||
<ShortcutsModal open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||||
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingGroupAdd}
|
||||
nodeLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.nodeId)?.data.label ?? '') : ''}
|
||||
nodeLabels={pendingGroupAdd ? labelsOf(pendingGroupAdd.nodeIds) : []}
|
||||
targetLabel={pendingGroupAdd ? (nodes.find((n) => n.id === pendingGroupAdd.groupId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingGroupAdd) addToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeId)
|
||||
if (pendingGroupAdd) addNodesToGroup(pendingGroupAdd.groupId, pendingGroupAdd.nodeIds)
|
||||
setPendingGroupAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingGroupAdd(null)}
|
||||
@@ -1322,10 +1457,10 @@ export default function App() {
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingContainerAdd}
|
||||
variant="container"
|
||||
nodeLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.nodeId)?.data.label ?? '') : ''}
|
||||
nodeLabels={pendingContainerAdd ? labelsOf(pendingContainerAdd.nodeIds) : []}
|
||||
targetLabel={pendingContainerAdd ? (nodes.find((n) => n.id === pendingContainerAdd.containerId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingContainerAdd) addToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeId)
|
||||
if (pendingContainerAdd) addNodesToContainer(pendingContainerAdd.containerId, pendingContainerAdd.nodeIds)
|
||||
setPendingContainerAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingContainerAdd(null)}
|
||||
@@ -1334,10 +1469,10 @@ export default function App() {
|
||||
<ConfirmAddToGroupModal
|
||||
open={!!pendingZoneAdd}
|
||||
variant="zone"
|
||||
nodeLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.nodeId)?.data.label ?? '') : ''}
|
||||
nodeLabels={pendingZoneAdd ? labelsOf(pendingZoneAdd.nodeIds) : []}
|
||||
targetLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.zoneId)?.data.label ?? '') : ''}
|
||||
onConfirm={() => {
|
||||
if (pendingZoneAdd) addToZone(pendingZoneAdd.zoneId, pendingZoneAdd.nodeId)
|
||||
if (pendingZoneAdd) addNodesToZone(pendingZoneAdd.zoneId, pendingZoneAdd.nodeIds)
|
||||
setPendingZoneAdd(null)
|
||||
}}
|
||||
onCancel={() => setPendingZoneAdd(null)}
|
||||
@@ -1350,6 +1485,14 @@ export default function App() {
|
||||
<DeviceInventoryModal
|
||||
open={inventoryModalOpen}
|
||||
onClose={() => setInventoryModalOpen(false)}
|
||||
onOpenDocumentation={
|
||||
STANDALONE
|
||||
? undefined
|
||||
: (deviceId, label) => {
|
||||
setInventoryModalOpen(false)
|
||||
void openDocumentationFor(deviceId, label)
|
||||
}
|
||||
}
|
||||
highlightId={inventoryHighlightId}
|
||||
initialStatus={inventoryModalStatus}
|
||||
demoDevices={tourInventoryDemo ? DEMO_INVENTORY_DEVICES : undefined}
|
||||
|
||||
@@ -70,6 +70,19 @@ describe('api/client', () => {
|
||||
expect(publicApi.defaults.baseURL).toBe('/api/v1')
|
||||
})
|
||||
|
||||
it('mounts both instances under a configured base path', async () => {
|
||||
const before = instances.length
|
||||
vi.stubEnv('BASE_URL', '/homelab/')
|
||||
vi.resetModules()
|
||||
await import('../client')
|
||||
const [subApi, subPublicApi] = instances.slice(before)
|
||||
expect(subApi.defaults.baseURL).toBe('/homelab/api/v1')
|
||||
expect(subApi.defaults.withCredentials).toBe(true)
|
||||
expect(subPublicApi.defaults.baseURL).toBe('/homelab/api/v1')
|
||||
vi.unstubAllEnvs()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('exports `api` matching the first created instance', () => {
|
||||
expect(mod.api).toBe(api)
|
||||
})
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import { API_BASE_URL } from '@/utils/basePath'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import type { AuthMode, AuthUser } from '@/stores/authStore'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
|
||||
const publicApi = axios.create({ baseURL: '/api/v1' })
|
||||
const publicApi = axios.create({ baseURL: API_BASE_URL })
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const { token, csrfToken } = useAuthStore.getState()
|
||||
@@ -181,6 +182,12 @@ export const scanApi = {
|
||||
rescanDevice: (id: string, opts?: { full_ports?: boolean; ports?: string; http_probe_enabled?: boolean; verify_tls?: boolean }) =>
|
||||
api.post<ScanRunSummary>(`/scan/pending/${id}/rescan`, opts ?? {}),
|
||||
hidden: () => api.get('/scan/hidden'),
|
||||
/**
|
||||
* Inventory rows for the guests a Proxmox host runs, resolved from the
|
||||
* host→guest links the Proxmox import records. Empty for a non-host device.
|
||||
*/
|
||||
proxmoxChildren: (id: string) =>
|
||||
api.get<InventoryEntry[]>(`/scan/pending/${id}/proxmox-children`),
|
||||
runs: () => api.get('/scan/runs'),
|
||||
run: (runId: string) => api.get<ScanRunSummary>(`/scan/runs/${runId}`),
|
||||
clearPending: () => api.delete('/scan/pending'),
|
||||
@@ -205,6 +212,14 @@ export const scanApi = {
|
||||
skipped: number
|
||||
skipped_devices: SkippedDevice[]
|
||||
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
||||
/**
|
||||
* Fold several inventory rows into one, keeping `winnerId`. Returns the
|
||||
* survivor. The losers' facts, canvas nodes, rack mounts and documents move
|
||||
* onto it first — the identity match only runs when a row is created, so two
|
||||
* rows for one host never converge on their own.
|
||||
*/
|
||||
merge: (winnerId: string, loserIds: string[]) =>
|
||||
api.post<InventoryEntry>('/scan/pending/merge', { winner_id: winnerId, loser_ids: loserIds }),
|
||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||
bulkRestore: (ids: string[]) => api.post<{ restored: number; skipped: number }>('/scan/pending/bulk-restore', { device_ids: ids }),
|
||||
@@ -294,6 +309,62 @@ export const designsApi = {
|
||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||
}
|
||||
|
||||
export const documentsApi = {
|
||||
list: (params?: { kind?: string; parent_id?: string; device_id?: string; tag?: string }) =>
|
||||
api.get<import('@/documentation/types').DocumentSummary[]>('/documents', { params }),
|
||||
get: (id: string) => api.get<import('@/documentation/types').Doc>(`/documents/${id}`),
|
||||
create: (data: {
|
||||
title: string
|
||||
kind?: string
|
||||
parent_id?: string | null
|
||||
device_id?: string | null
|
||||
node_id?: string | null
|
||||
design_id?: string | null
|
||||
template_id?: string | null
|
||||
body?: string
|
||||
}) => api.post<import('@/documentation/types').Doc>('/documents', data),
|
||||
update: (
|
||||
id: string,
|
||||
data: {
|
||||
title?: string
|
||||
icon?: string | null
|
||||
body?: string
|
||||
parent_id?: string | null
|
||||
sort_order?: number
|
||||
starred?: boolean
|
||||
reviewed?: boolean
|
||||
resync_facts?: boolean
|
||||
},
|
||||
) => api.patch<import('@/documentation/types').Doc>(`/documents/${id}`, data),
|
||||
delete: (id: string) => api.delete(`/documents/${id}`),
|
||||
revisions: (id: string) =>
|
||||
api.get<import('@/documentation/types').DocRevision[]>(`/documents/${id}/revisions`),
|
||||
revision: (revisionId: string) =>
|
||||
api.get<import('@/documentation/types').DocRevision & { body: string }>(
|
||||
`/documents/revisions/${revisionId}`,
|
||||
),
|
||||
restore: (id: string, revisionId: string) =>
|
||||
api.post<import('@/documentation/types').Doc>(`/documents/${id}/revisions/${revisionId}/restore`),
|
||||
backlinks: (id: string) =>
|
||||
api.get<import('@/documentation/types').DocBacklink[]>(`/documents/${id}/backlinks`),
|
||||
regenerate: (id: string) =>
|
||||
api.post<import('@/documentation/types').Doc>(`/documents/${id}/regenerate`),
|
||||
search: (q: string, limit = 25) =>
|
||||
api.get<import('@/documentation/types').DocSearchResult>('/documents/search', {
|
||||
params: { q, limit },
|
||||
}),
|
||||
block: (block: string, deviceId: string) =>
|
||||
api.get<{ block: string; markdown: string }>('/documents/blocks', {
|
||||
params: { block, device_id: deviceId },
|
||||
}),
|
||||
coverage: () => api.get<import('@/documentation/types').DocCoverage>('/documents/coverage'),
|
||||
scaffold: (data: { device_ids?: string[]; only_with_notes?: boolean }) =>
|
||||
api.post<{ created: import('@/documentation/types').DocumentSummary[]; skipped: number }>(
|
||||
'/documents/scaffold',
|
||||
data,
|
||||
),
|
||||
}
|
||||
|
||||
export const racksApi = {
|
||||
load: (designId: string) =>
|
||||
api.get<import('@/utils/rackSerializer').ApiRackState>('/racks', { params: { design_id: designId } }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Logo } from '@/components/ui/Logo'
|
||||
import { authApi } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { API_BASE_URL, resolveServerPath } from '@/utils/basePath'
|
||||
|
||||
export function LoginPage() {
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -111,7 +112,7 @@ export function LoginPage() {
|
||||
Continue through the configured OpenID Connect provider.
|
||||
</p>
|
||||
<a
|
||||
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
|
||||
href={resolveServerPath(oidcLoginUrl ?? `${API_BASE_URL}/auth/oidc/login`)}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type Viewport,
|
||||
} from '@xyflow/react'
|
||||
import { MousePointer2, Hand } from 'lucide-react'
|
||||
import '@xyflow/react/dist/style.css'
|
||||
@@ -32,9 +33,9 @@ interface CanvasContainerProps {
|
||||
onEdgeDoubleClick?: (edge: Edge<EdgeData>) => void
|
||||
onNodeDoubleClick?: (node: Node<NodeData>) => void
|
||||
onNodeDragStart?: () => void
|
||||
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
|
||||
onRequestAddToZone?: (payload: { nodeId: string; zoneId: string }) => void
|
||||
onRequestAddToGroup?: (payload: { nodeIds: string[]; groupId: string }) => void
|
||||
onRequestAddToContainer?: (payload: { nodeIds: string[]; containerId: string }) => void
|
||||
onRequestAddToZone?: (payload: { nodeIds: string[]; zoneId: string }) => void
|
||||
onOpenInventory?: (deviceId: string) => void
|
||||
}
|
||||
|
||||
@@ -45,10 +46,16 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodesChange, onEdgesChange,
|
||||
setSelectedNode, snapshotHistory,
|
||||
fitViewPending, clearFitViewPending,
|
||||
savedViewport, setSavedViewport,
|
||||
copySelectedNodes, pasteNodes,
|
||||
removeFromGroup,
|
||||
removeNodesFromGroup,
|
||||
} = useCanvasStore()
|
||||
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||
const { fitView, getViewport, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
|
||||
|
||||
// React Flow reads defaultViewport once, on mount. Leaving the canvas
|
||||
// (Documentation) unmounts it, so hold the stored pan/zoom as it was at first
|
||||
// render: later saves must not feed back into the prop.
|
||||
const [initialViewport] = useState(savedViewport)
|
||||
|
||||
// Track the last cursor position over the canvas so paste lands under it.
|
||||
const cursorRef = useRef<{ x: number; y: number } | null>(null)
|
||||
@@ -93,11 +100,14 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
const id = setTimeout(() => {
|
||||
fitView({ padding: 0.12, duration: 350 })
|
||||
void fitView({ padding: 0.12, duration: 350 }).then(() => {
|
||||
// Where the fit landed is the viewport to come back to.
|
||||
setSavedViewport(getViewport())
|
||||
})
|
||||
clearFitViewPending()
|
||||
}, 50)
|
||||
return () => clearTimeout(id)
|
||||
}, [fitViewPending, nodes.length, fitView, clearFitViewPending])
|
||||
}, [fitViewPending, nodes.length, fitView, getViewport, setSavedViewport, clearFitViewPending])
|
||||
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const theme = THEMES[activeTheme]
|
||||
@@ -125,6 +135,12 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
setSelectedNode(null)
|
||||
}, [setSelectedNode])
|
||||
|
||||
// Every pan / zoom the user ends is remembered, so leaving the canvas and
|
||||
// coming back does not reset the view to 1:1 at the origin.
|
||||
const handleMoveEnd = useCallback((_: unknown, viewport: Viewport) => {
|
||||
setSavedViewport(viewport)
|
||||
}, [setSavedViewport])
|
||||
|
||||
const handleEdgeDoubleClick = useCallback((_: React.MouseEvent, edge: Edge<EdgeData>) => {
|
||||
onEdgeDoubleClick?.(edge)
|
||||
}, [onEdgeDoubleClick])
|
||||
@@ -145,38 +161,61 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
|
||||
const { guides, onNodeDrag, onNodeDragStop } = useAlignmentGuides()
|
||||
|
||||
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
|
||||
// before the alignment snap so detection uses the dropped position.
|
||||
// Drop a selection onto a group → ask App to confirm adding it. Runs before
|
||||
// the alignment snap so detection uses the dropped position. `dragNodes` is
|
||||
// the whole multi-selection being dragged; `dragNode` (the one under the
|
||||
// cursor) only picks the destination.
|
||||
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
|
||||
if (dragNode && dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
|
||||
// A single drag reports an empty `dragNodes` in some React Flow paths, so
|
||||
// fall back to the dragged node itself.
|
||||
const dragged = dragNodes && dragNodes.length > 0 ? dragNodes : dragNode ? [dragNode] : []
|
||||
// Groups and zones are containers, never contents.
|
||||
const movable = dragged.filter((n) => n.data.type !== 'group' && n.data.type !== 'groupRect')
|
||||
|
||||
if (dragNode && dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect' && movable.length > 0) {
|
||||
const intersecting = getIntersectingNodes(dragNode)
|
||||
const zoneParent = dragNode.parentId
|
||||
? nodes.find((n) => n.id === dragNode.parentId && n.data.type === 'groupRect')
|
||||
: undefined
|
||||
if (zoneParent) {
|
||||
// Zone children are not extent-clamped, so a drop outside the zone is
|
||||
// how the user takes a node back out of it.
|
||||
// how the user takes a node back out of it. The whole selection leaves
|
||||
// with it, not only the node under the cursor.
|
||||
if (!intersecting.some((n) => n.id === zoneParent.id)) {
|
||||
removeFromGroup(zoneParent.id, dragNode.id)
|
||||
const leaving = movable.filter((n) => n.parentId === zoneParent.id).map((n) => n.id)
|
||||
// One history entry, so a single undo puts the whole selection back —
|
||||
// symmetric with the batched add.
|
||||
removeNodesFromGroup(zoneParent.id, leaving)
|
||||
}
|
||||
} else if (!dragNode.parentId) {
|
||||
// Only free nodes join a new parent; one already nested elsewhere in the
|
||||
// selection keeps its own parent.
|
||||
const nodeIds = movable.filter((n) => !n.parentId).map((n) => n.id)
|
||||
const group = intersecting.find((n) => n.data.type === 'group')
|
||||
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
|
||||
// The destination can be part of the dragged selection (lasso over
|
||||
// everything); it cannot also be one of its own new children.
|
||||
const targetless = (targetId: string) => nodeIds.filter((id) => id !== targetId)
|
||||
if (group) {
|
||||
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
|
||||
const ids = targetless(group.id)
|
||||
if (ids.length > 0) onRequestAddToGroup?.({ nodeIds: ids, groupId: group.id })
|
||||
} else if (container) {
|
||||
// Any node in container_mode (proxmox, docker_host, …) accepts children.
|
||||
onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
|
||||
const ids = targetless(container.id)
|
||||
if (ids.length > 0) onRequestAddToContainer?.({ nodeIds: ids, containerId: container.id })
|
||||
} else {
|
||||
// Zones come last: they are the loosest container and the largest, so
|
||||
// a group/container inside one still wins the drop.
|
||||
const zone = intersecting.find((n) => n.data.type === 'groupRect')
|
||||
if (zone) onRequestAddToZone?.({ nodeId: dragNode.id, zoneId: zone.id })
|
||||
if (zone) {
|
||||
const ids = targetless(zone.id)
|
||||
if (ids.length > 0) onRequestAddToZone?.({ nodeIds: ids, zoneId: zone.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
onNodeDragStop(event, dragNode, dragNodes)
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, removeFromGroup, nodes, getIntersectingNodes, onNodeDragStop])
|
||||
}, [onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, removeNodesFromGroup, nodes, getIntersectingNodes, onNodeDragStop])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
|
||||
@@ -193,6 +232,8 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
defaultViewport={initialViewport ?? undefined}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { resolveServerPath } from '@/utils/basePath'
|
||||
|
||||
interface ResizeState {
|
||||
startMouseX: number
|
||||
@@ -152,7 +153,7 @@ export function FloorMapLayer() {
|
||||
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
|
||||
>
|
||||
<img
|
||||
src={imageData}
|
||||
src={resolveServerPath(imageData)}
|
||||
alt="Floor plan"
|
||||
draggable={false}
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { CanvasContainer } from '../CanvasContainer'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
@@ -10,7 +10,11 @@ import type { NodeData, EdgeData } from '@/types'
|
||||
let rfProps: Record<string, unknown> = {}
|
||||
|
||||
// Hoisted holder so the mock factory can read the configurable intersection set.
|
||||
const rf = vi.hoisted(() => ({ intersecting: [] as unknown[] }))
|
||||
const rf = vi.hoisted(() => ({
|
||||
intersecting: [] as unknown[],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
fitView: vi.fn(() => Promise.resolve(true)),
|
||||
}))
|
||||
|
||||
vi.mock('@xyflow/react', () => ({
|
||||
ReactFlow: (props: Record<string, unknown>) => {
|
||||
@@ -25,7 +29,8 @@ vi.mock('@xyflow/react', () => ({
|
||||
SelectionMode: { Partial: 'partial' },
|
||||
Position: { Top: 'top', Right: 'right', Bottom: 'bottom', Left: 'left' },
|
||||
useReactFlow: () => ({
|
||||
fitView: vi.fn(),
|
||||
fitView: rf.fitView,
|
||||
getViewport: () => rf.viewport,
|
||||
screenToFlowPosition: vi.fn(),
|
||||
getIntersectingNodes: () => rf.intersecting,
|
||||
setNodes: vi.fn(),
|
||||
@@ -52,7 +57,12 @@ describe('CanvasContainer', () => {
|
||||
beforeEach(() => {
|
||||
rfProps = {}
|
||||
rf.intersecting = []
|
||||
useCanvasStore.setState({ nodes: [], edges: [], selectedNodeId: null })
|
||||
rf.viewport = { x: 0, y: 0, zoom: 1 }
|
||||
rf.fitView.mockClear()
|
||||
useCanvasStore.setState({
|
||||
nodes: [], edges: [], selectedNodeId: null,
|
||||
savedViewport: null, fitViewPending: false,
|
||||
})
|
||||
useThemeStore.setState({ activeTheme: 'default' })
|
||||
})
|
||||
|
||||
@@ -177,7 +187,7 @@ describe('CanvasContainer', () => {
|
||||
rf.intersecting = [group]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeIds: ['n1'], groupId: 'g1' })
|
||||
})
|
||||
|
||||
it('does not fire onRequestAddToGroup when no group is under the node', () => {
|
||||
@@ -219,7 +229,7 @@ describe('CanvasContainer', () => {
|
||||
rf.intersecting = [containerNode('px1')]
|
||||
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeIds: ['n1'], containerId: 'px1' })
|
||||
})
|
||||
|
||||
it('prefers a group over a container when both intersect', () => {
|
||||
@@ -229,7 +239,7 @@ describe('CanvasContainer', () => {
|
||||
rf.intersecting = [containerNode('px1'), groupNode('g1')]
|
||||
render(<CanvasContainer onRequestAddToGroup={onRequestAddToGroup} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeId: 'n1', groupId: 'g1' })
|
||||
expect(onRequestAddToGroup).toHaveBeenCalledWith({ nodeIds: ['n1'], groupId: 'g1' })
|
||||
expect(onRequestAddToContainer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -263,7 +273,7 @@ describe('CanvasContainer', () => {
|
||||
rf.intersecting = [zoneNode('z1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeId: 'n1', zoneId: 'z1' })
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeIds: ['n1'], zoneId: 'z1' })
|
||||
})
|
||||
|
||||
it('prefers a group and a container over a zone when they overlap', () => {
|
||||
@@ -273,7 +283,7 @@ describe('CanvasContainer', () => {
|
||||
rf.intersecting = [zoneNode('z1'), containerNode('px1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeIds: ['n1'], containerId: 'px1' })
|
||||
expect(onRequestAddToZone).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -308,6 +318,64 @@ describe('CanvasContainer', () => {
|
||||
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('z1')
|
||||
})
|
||||
|
||||
// ── Multi-selection drops (#365) ──────────────────────────────────────────
|
||||
|
||||
it('fires onRequestAddToZone with every dragged node, not just the one under the cursor', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const dragged = [makeNode('n1'), makeNode('n2'), makeNode('n3')]
|
||||
rf.intersecting = [zoneNode('z1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, dragged[0], dragged)
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeIds: ['n1', 'n2', 'n3'], zoneId: 'z1' })
|
||||
})
|
||||
|
||||
it('leaves zones and groups out of a dragged selection', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const dragged = [makeNode('n1'), zoneNode('z1'), groupNode('g1')]
|
||||
rf.intersecting = [zoneNode('z2')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, dragged[0], dragged)
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeIds: ['n1'], zoneId: 'z2' })
|
||||
})
|
||||
|
||||
it('never asks to add the destination container to itself', () => {
|
||||
const onRequestAddToContainer = vi.fn()
|
||||
const target = containerNode('px1')
|
||||
const dragged = [makeNode('n1'), target]
|
||||
rf.intersecting = [target]
|
||||
render(<CanvasContainer onRequestAddToContainer={onRequestAddToContainer} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, dragged[0], dragged)
|
||||
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeIds: ['n1'], containerId: 'px1' })
|
||||
})
|
||||
|
||||
it('keeps an already-parented node in the selection with its own parent', () => {
|
||||
const onRequestAddToZone = vi.fn()
|
||||
const dragged = [makeNode('n1'), { ...makeNode('n2'), parentId: 'pxOther' }]
|
||||
rf.intersecting = [zoneNode('z1')]
|
||||
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, dragged[0], dragged)
|
||||
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeIds: ['n1'], zoneId: 'z1' })
|
||||
})
|
||||
|
||||
it('detaches every dragged child when a selection leaves its zone', () => {
|
||||
const zone = { ...zoneNode('z1'), position: { x: 100, y: 100 } }
|
||||
const c1 = { ...makeNode('n1'), parentId: 'z1', position: { x: 20, y: 20 } }
|
||||
const c2 = { ...makeNode('n2'), parentId: 'z1', position: { x: 40, y: 40 } }
|
||||
useCanvasStore.setState({ nodes: [zone, c1, c2] })
|
||||
rf.intersecting = []
|
||||
render(<CanvasContainer />)
|
||||
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, c1, [c1, c2])
|
||||
const after = useCanvasStore.getState().nodes
|
||||
expect(after.find((n) => n.id === 'n1')!.parentId).toBeUndefined()
|
||||
expect(after.find((n) => n.id === 'n2')!.parentId).toBeUndefined()
|
||||
expect(after.find((n) => n.id === 'n2')!.position).toEqual({ x: 140, y: 140 })
|
||||
// One history entry for the whole selection: a single undo puts both back.
|
||||
useCanvasStore.getState().undo()
|
||||
const restored = useCanvasStore.getState().nodes
|
||||
expect(restored.find((n) => n.id === 'n1')!.parentId).toBe('z1')
|
||||
expect(restored.find((n) => n.id === 'n2')!.parentId).toBe('z1')
|
||||
})
|
||||
|
||||
// ── Canvas settings ───────────────────────────────────────────────────────
|
||||
|
||||
it('enables snapToGrid', () => {
|
||||
@@ -386,4 +454,60 @@ describe('CanvasContainer', () => {
|
||||
expect(snapshotHistory).toHaveBeenCalledOnce()
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
// ── Viewport persistence across unmount (#docs round-trip) ────────────────
|
||||
|
||||
it('onMoveEnd stores the viewport the user ended on', () => {
|
||||
render(<CanvasContainer />)
|
||||
act(() => {
|
||||
;(rfProps.onMoveEnd as (...args: unknown[]) => unknown)({}, { x: -300, y: 120, zoom: 0.5 })
|
||||
})
|
||||
expect(useCanvasStore.getState().savedViewport).toEqual({ x: -300, y: 120, zoom: 0.5 })
|
||||
})
|
||||
|
||||
it('mounts at the stored viewport — leaving for Documentation must not reset to 1:1', () => {
|
||||
useCanvasStore.setState({ savedViewport: { x: -300, y: 120, zoom: 0.5 } })
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.defaultViewport).toEqual({ x: -300, y: 120, zoom: 0.5 })
|
||||
})
|
||||
|
||||
it('keeps the stored viewport across an unmount / remount round-trip', () => {
|
||||
const first = render(<CanvasContainer />)
|
||||
act(() => {
|
||||
;(rfProps.onMoveEnd as (...args: unknown[]) => unknown)({}, { x: -80, y: 15, zoom: 0.75 })
|
||||
})
|
||||
first.unmount()
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.defaultViewport).toEqual({ x: -80, y: 15, zoom: 0.75 })
|
||||
})
|
||||
|
||||
it('leaves defaultViewport undefined on a first load, so React Flow fits', () => {
|
||||
render(<CanvasContainer />)
|
||||
expect(rfProps.defaultViewport).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not feed a later pan back into defaultViewport (read once, on mount)', () => {
|
||||
render(<CanvasContainer />)
|
||||
act(() => {
|
||||
;(rfProps.onMoveEnd as (...args: unknown[]) => unknown)({}, { x: -80, y: 15, zoom: 0.75 })
|
||||
})
|
||||
expect(rfProps.defaultViewport).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stores where fitView landed, so the fitted view survives the round-trip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
rf.viewport = { x: -42, y: 7, zoom: 0.6 }
|
||||
useCanvasStore.setState({ nodes: [makeNode('n1')], fitViewPending: true })
|
||||
render(<CanvasContainer />)
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
})
|
||||
expect(rf.fitView).toHaveBeenCalledOnce()
|
||||
expect(useCanvasStore.getState().savedViewport).toEqual({ x: -42, y: 7, zoom: 0.6 })
|
||||
expect(useCanvasStore.getState().fitViewPending).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { FloorMapLayer } from '../FloorMapLayer'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import type { FloorMapConfig } from '@/types'
|
||||
@@ -43,6 +43,16 @@ describe('FloorMapLayer', () => {
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the stored image URL as-is at the root, base64 plans included', () => {
|
||||
setFloorMap()
|
||||
const { rerender } = render(<FloorMapLayer />)
|
||||
expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('/api/v1/media/abc.png')
|
||||
|
||||
act(() => setFloorMap({ imageData: 'data:image/png;base64,abc' }))
|
||||
rerender(<FloorMapLayer />)
|
||||
expect(screen.getByAltText('Floor plan').getAttribute('src')).toBe('data:image/png;base64,abc')
|
||||
})
|
||||
|
||||
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
|
||||
setFloorMap()
|
||||
render(<FloorMapLayer />)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { render } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||
@@ -13,15 +15,18 @@ import type { EdgeData } from '@/types'
|
||||
* CSS animations pause when the tab is hidden and don't leak — so the rendered
|
||||
* output must contain a CSS `animation` on the path and zero <animate> nodes.
|
||||
*/
|
||||
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||
function renderEdge(
|
||||
data: Partial<EdgeData> = {},
|
||||
coords: { sourceY?: number; targetY?: number } = {},
|
||||
) {
|
||||
const props = {
|
||||
id: 'e1',
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
sourceX: 0,
|
||||
sourceY: 0,
|
||||
sourceY: coords.sourceY ?? 0,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
targetY: coords.targetY ?? 100,
|
||||
sourcePosition: 'bottom',
|
||||
targetPosition: 'top',
|
||||
data: { type: 'ethernet', ...data } as EdgeData,
|
||||
@@ -65,6 +70,53 @@ describe('HomelableEdge animation', () => {
|
||||
expect(animated).toBeTruthy()
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression (#395): the basic dash used to flip to `animation-direction:
|
||||
* reverse` whenever the source sat below the target on screen, so an edge
|
||||
* drawn bottom-to-top animated as if it went target→source. Direction comes
|
||||
* from the data (the path is built source→target), never from the geometry.
|
||||
*/
|
||||
it('basic dash animates source→target when the target is below', () => {
|
||||
const { container } = renderEdge({ animated: 'basic' }, { sourceY: 0, targetY: 100 })
|
||||
const dashed = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||
(p.getAttribute('style') ?? '').includes('homelable-basic-dash'),
|
||||
)
|
||||
expect(dashed).toBeTruthy()
|
||||
expect(dashed!.getAttribute('style') ?? '').not.toContain('reverse')
|
||||
})
|
||||
|
||||
it('basic dash keeps the same direction when the target is above', () => {
|
||||
const { container } = renderEdge({ animated: 'basic' }, { sourceY: 100, targetY: 0 })
|
||||
const dashed = Array.from(container.querySelectorAll('path')).find((p) =>
|
||||
(p.getAttribute('style') ?? '').includes('homelable-basic-dash'),
|
||||
)
|
||||
expect(dashed).toBeTruthy()
|
||||
expect(dashed!.getAttribute('style') ?? '').not.toContain('reverse')
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression (#395): the basic dash marched against snake and flow because
|
||||
* its keyframes decreased stroke-dashoffset while theirs increase it. All
|
||||
* three must move the same way along the path.
|
||||
*/
|
||||
it('basic dash keyframes march the same way as snake and flow', () => {
|
||||
const css = readFileSync(resolve(__dirname, '../../../../index.css'), 'utf8')
|
||||
const offsets = (name: string) => {
|
||||
const block = css.match(new RegExp(`@keyframes ${name} \\{([^}]*\\}[^}]*)\\}`))
|
||||
expect(block, `${name} keyframes not found`).toBeTruthy()
|
||||
const values = [...block![1].matchAll(/stroke-dashoffset:\s*(-?\d+)/g)].map((m) => Number(m[1]))
|
||||
expect(values).toHaveLength(2)
|
||||
return values
|
||||
}
|
||||
const rising = (name: string) => {
|
||||
const [from, to] = offsets(name)
|
||||
return to > from
|
||||
}
|
||||
expect(rising('homelable-basic-dash')).toBe(true)
|
||||
expect(rising('homelable-snake')).toBe(true)
|
||||
expect(rising('homelable-flow')).toBe(true)
|
||||
})
|
||||
|
||||
it('non-animated edge has no flow animation and no <animate>', () => {
|
||||
const { container } = renderEdge({ animated: false })
|
||||
expect(container.querySelector('animate')).toBeNull()
|
||||
|
||||
@@ -442,8 +442,11 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
||||
strokeDasharray="5"
|
||||
style={{
|
||||
pointerEvents: 'none',
|
||||
// Direction comes from the keyframes alone (see index.css), never
|
||||
// from the endpoints' screen positions: an edge drawn bottom-to-top
|
||||
// marches the same way as one drawn top-to-bottom, like the snake
|
||||
// and flow modes.
|
||||
animation: 'homelable-basic-dash 0.5s linear infinite',
|
||||
animationDirection: sourceY <= targetY ? 'normal' : 'reverse',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { brandIconUrl, BRAND_ICON_PREFIX } from '@/utils/nodeIcons'
|
||||
import { brandIconUrl, BRAND_ICON_PREFIX, LOCAL_BRAND_ICONS } from '@/utils/nodeIcons'
|
||||
import dashboardIcons from '@/data/dashboardIcons.json'
|
||||
|
||||
const SLUGS: string[] = dashboardIcons as string[]
|
||||
// Locally-served logos first, then the generated dashboard-icons manifest.
|
||||
const SLUGS: string[] = [
|
||||
...LOCAL_BRAND_ICONS,
|
||||
...(dashboardIcons as string[]).filter((s) => !(LOCAL_BRAND_ICONS as readonly string[]).includes(s)),
|
||||
]
|
||||
const PAGE = 120
|
||||
|
||||
interface BrandIconPickerProps {
|
||||
|
||||
@@ -9,9 +9,14 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** Labels past this many are summarized as "+N more" instead of listed. */
|
||||
const MAX_LISTED_LABELS = 5
|
||||
|
||||
interface ConfirmAddToGroupModalProps {
|
||||
open: boolean
|
||||
nodeLabel: string
|
||||
/** Labels of the nodes being added — one entry for a single drop, several for
|
||||
* a multi-selection. */
|
||||
nodeLabels: string[]
|
||||
/** Label of the destination group/container. */
|
||||
targetLabel: string
|
||||
/** Destination kind — drives the wording. Defaults to 'group'. */
|
||||
@@ -22,7 +27,7 @@ interface ConfirmAddToGroupModalProps {
|
||||
|
||||
export function ConfirmAddToGroupModal({
|
||||
open,
|
||||
nodeLabel,
|
||||
nodeLabels,
|
||||
targetLabel,
|
||||
variant = 'group',
|
||||
onConfirm,
|
||||
@@ -30,6 +35,14 @@ export function ConfirmAddToGroupModal({
|
||||
}: ConfirmAddToGroupModalProps) {
|
||||
const noun = variant === 'container' ? 'container' : variant === 'zone' ? 'zone' : 'group'
|
||||
const action = `Add to ${noun}`
|
||||
const count = nodeLabels.length
|
||||
const listed = nodeLabels.slice(0, MAX_LISTED_LABELS).join(', ')
|
||||
const subject =
|
||||
count === 1
|
||||
? nodeLabels[0]
|
||||
: count <= MAX_LISTED_LABELS
|
||||
? `${count} nodes (${listed})`
|
||||
: `${count} nodes (${listed}, +${count - MAX_LISTED_LABELS} more)`
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
@@ -39,7 +52,7 @@ export function ConfirmAddToGroupModal({
|
||||
{action}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add <span className="font-medium text-foreground">{nodeLabel}</span> to the {noun}{' '}
|
||||
Add <span className="font-medium text-foreground">{subject}</span> to the {noun}{' '}
|
||||
<span className="font-medium text-foreground">{targetLabel}</span>?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
|
||||
import type { Design, DesignType, FloorMapConfig } from '@/types'
|
||||
import { resolveServerPath } from '@/utils/basePath'
|
||||
|
||||
/** Canvas kinds offered on create. `electrical` shares the network renderer. */
|
||||
const CANVAS_KINDS: { value: DesignType; label: string; hint: string }[] = [
|
||||
@@ -294,7 +295,7 @@ export function DesignModal({
|
||||
) : (
|
||||
<>
|
||||
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
|
||||
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
<img src={resolveServerPath(imageData)} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -21,6 +21,9 @@ import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||
import { sourceBuckets, orderedSources, isRackDevice, SOURCE_META, type SourceBucket } from '@/utils/deviceSources'
|
||||
import { isRackable } from '@/utils/rackable'
|
||||
import { ProxmoxApproveModal, type ProxmoxApproveChoice } from '@/components/modals/ProxmoxApproveModal'
|
||||
import { MergeDevicesModal } from '@/components/modals/MergeDevicesModal'
|
||||
import { layoutProxmoxContainers, measureProxmoxContainers } from '@/utils/proxmoxContainerLayout'
|
||||
|
||||
interface DeviceInventoryModalProps {
|
||||
open: boolean
|
||||
@@ -37,6 +40,12 @@ interface DeviceInventoryModalProps {
|
||||
onPick?: (device: InventoryEntry) => void
|
||||
/** Start with the Rackable filter on (what the rack picker wants). */
|
||||
initialRackableOnly?: boolean
|
||||
/**
|
||||
* Opens the Documentation section on a device, from its detail modal. The
|
||||
* list closes on the way out — Documentation is a whole view behind these
|
||||
* dialogs, not something to read under them.
|
||||
*/
|
||||
onOpenDocumentation?: (deviceId: string, label: string) => void
|
||||
}
|
||||
|
||||
const PORT_COLORS: Record<number, string> = {
|
||||
@@ -113,6 +122,34 @@ function extractDuplicateConflict(err: unknown): DuplicateNodeConflict | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The canvas node an inventory row becomes. Shared by every path that places a
|
||||
* device (single approve, bulk approve, the Proxmox host + guests flow) so they
|
||||
* cannot drift on labels, properties or the wireless status shortcut.
|
||||
*/
|
||||
function inventoryNodeData(d: InventoryEntry) {
|
||||
const type = deviceType(d) ?? 'generic'
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
return {
|
||||
type,
|
||||
data: {
|
||||
label: deviceLabel(d),
|
||||
type,
|
||||
ip: d.ip ?? undefined,
|
||||
mac: d.mac ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: wireless ? ('online' as const) : ('unknown' as const),
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
properties: zwave
|
||||
? buildZwaveProperties(d)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(d)
|
||||
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||
if (!edges || edges.length === 0) return
|
||||
useCanvasStore.setState((state) => ({
|
||||
@@ -121,7 +158,7 @@ function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||
}))
|
||||
}
|
||||
|
||||
export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices, onPick, initialRackableOnly = false }: DeviceInventoryModalProps) {
|
||||
export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus = 'pending', demoDevices, onPick, initialRackableOnly = false, onOpenDocumentation }: DeviceInventoryModalProps) {
|
||||
const [devices, setDevices] = useState<InventoryEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<InventoryEntry | null>(null)
|
||||
@@ -145,6 +182,12 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
// Set when a single approve is refused because the same host is already on
|
||||
// this design — the user decides: link to the existing node or duplicate.
|
||||
const [dupPrompt, setDupPrompt] = useState<{ device: InventoryEntry; conflict: DuplicateNodeConflict } | null>(null)
|
||||
// Set when a Proxmox host with guests is about to be placed — the user picks
|
||||
// whether the guests come along, and nested or linked.
|
||||
const [proxmoxPrompt, setProxmoxPrompt] = useState<{ host: InventoryEntry; children: InventoryEntry[] } | null>(null)
|
||||
// Set when the user asks to merge the selected rows — they pick the survivor.
|
||||
const [mergePrompt, setMergePrompt] = useState<InventoryEntry[] | null>(null)
|
||||
const [merging, setMerging] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
// Tour/demo mode: show injected devices, never touch the backend.
|
||||
@@ -203,10 +246,29 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
})
|
||||
}, [devices, search, sourceFilter, typeFilter, statusFilter, showOnCanvas, withServicesOnly, rackableOnly])
|
||||
|
||||
// The device a caller asked for, opened once. Scrolling the card into view
|
||||
// was never the whole request — "open in inventory" means show me that
|
||||
// device, so its detail modal comes up with it. Remembering which id was
|
||||
// already honoured keeps a later filter change from reopening a modal the
|
||||
// user closed, and picker mode is left alone: it wants a device back, not a
|
||||
// second dialog on top.
|
||||
const openedHighlight = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
if (!highlightId || loading || !open) return
|
||||
highlightRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [highlightId, loading, open, filtered])
|
||||
if (!open) {
|
||||
openedHighlight.current = undefined
|
||||
return
|
||||
}
|
||||
if (!highlightId || loading) return
|
||||
if (!onPick && openedHighlight.current !== highlightId) {
|
||||
const device = devices.find((d) => d.id === highlightId)
|
||||
if (device) {
|
||||
openedHighlight.current = highlightId
|
||||
setSelected(device)
|
||||
}
|
||||
}
|
||||
// Last, so a browser without it cannot cost the caller the detail modal.
|
||||
highlightRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' })
|
||||
}, [devices, highlightId, loading, onPick, open, filtered])
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
@@ -330,7 +392,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||
toast.success(`Put ${nodeData.label} in the canvas${extra}`)
|
||||
// Keep the row (now on-canvas, shown with an "In N canvas" badge); reload
|
||||
// for a fresh canvas_count rather than dropping it until reopen.
|
||||
setSelected(null)
|
||||
@@ -351,7 +413,104 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = (device: InventoryEntry) => approveDevice(device, false)
|
||||
// A Proxmox host is rarely wanted on its own: ask about its guests first.
|
||||
// Anything else — and a host with no guests in the inventory — goes straight
|
||||
// through the normal single-device approve.
|
||||
const handleApprove = (device: InventoryEntry) => {
|
||||
if (deviceType(device) === 'proxmox') { void promptProxmoxChildren(device); return }
|
||||
return approveDevice(device, false)
|
||||
}
|
||||
|
||||
const promptProxmoxChildren = async (device: InventoryEntry) => {
|
||||
let guests: InventoryEntry[] = []
|
||||
try {
|
||||
guests = (await scanApi.proxmoxChildren(device.id)).data
|
||||
} catch {
|
||||
// No link data (or the call failed): fall back to placing the host alone.
|
||||
guests = []
|
||||
}
|
||||
if (guests.length === 0) { await approveDevice(device, false); return }
|
||||
// Close the device-detail modal first: two Base UI dialogs open at once trap
|
||||
// focus on the underlying one and the prompt never shows.
|
||||
setSelected(null)
|
||||
setProxmoxPrompt({ host: device, children: guests })
|
||||
}
|
||||
|
||||
const handleProxmoxConfirm = async ({ childIds, mode }: ProxmoxApproveChoice) => {
|
||||
const prompt = proxmoxPrompt
|
||||
if (!prompt) return
|
||||
setProxmoxPrompt(null)
|
||||
if (childIds.length === 0) { await approveDevice(prompt.host, false); return }
|
||||
|
||||
const wanted = new Set(childIds)
|
||||
const guests = prompt.children.filter((c) => wanted.has(c.id))
|
||||
try {
|
||||
const res = await scanApi.bulkApprove([prompt.host.id, ...guests.map((g) => g.id)], activeDesignId)
|
||||
const deviceToNode: Record<string, string> = {}
|
||||
res.data.device_ids.forEach((did, i) => { deviceToNode[did] = res.data.node_ids[i] })
|
||||
|
||||
const hostNodeId = deviceToNode[prompt.host.id]
|
||||
const placedGuests = guests.filter((g) => deviceToNode[g.id])
|
||||
const guestNodeIds = placedGuests.map((g) => deviceToNode[g.id])
|
||||
|
||||
// The host was skipped (already on this canvas): there is no container to
|
||||
// nest into, so drop the guests in a plain grid instead.
|
||||
const nested = mode === 'container' && !!hostNodeId
|
||||
const hostIds = hostNodeId ? [hostNodeId] : []
|
||||
const childrenByHost: Record<string, string[]> = hostNodeId
|
||||
? { [hostNodeId]: nested ? guestNodeIds : [] }
|
||||
: {}
|
||||
const loose = nested ? [] : guestNodeIds
|
||||
const measured = measureProxmoxContainers(hostIds, childrenByHost, loose.length)
|
||||
const origin = getCenteredPosition(measured.width, measured.height)
|
||||
const { positions, hostSizes } = layoutProxmoxContainers(hostIds, childrenByHost, loose, origin)
|
||||
|
||||
if (hostNodeId) {
|
||||
const { type, data } = inventoryNodeData(prompt.host)
|
||||
const size = hostSizes[hostNodeId]
|
||||
addNode({
|
||||
id: hostNodeId,
|
||||
type,
|
||||
position: positions[hostNodeId],
|
||||
...(nested && size ? { width: size.width, height: size.height } : {}),
|
||||
data: { ...data, ...(nested ? { container_mode: true } : {}) },
|
||||
})
|
||||
}
|
||||
// Hosts are added first above — addNode resolves `parent_id` against what
|
||||
// is already in the store.
|
||||
placedGuests.forEach((g) => {
|
||||
const nodeId = deviceToNode[g.id]
|
||||
const { type, data } = inventoryNodeData(g)
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type,
|
||||
position: positions[nodeId] ?? getCenteredPosition(),
|
||||
data: { ...data, ...(nested && hostNodeId ? { parent_id: hostNodeId } : {}) },
|
||||
})
|
||||
})
|
||||
// A nested guest needs no edge back to its host — being inside the box
|
||||
// already says it runs there, and the edge only loops out and back in.
|
||||
const nestedIds = new Set(nested ? guestNodeIds : [])
|
||||
injectAutoEdges(res.data.edges.filter((e) => !(
|
||||
(e.source === hostNodeId && nestedIds.has(e.target))
|
||||
|| (e.target === hostNodeId && nestedIds.has(e.source))
|
||||
)))
|
||||
setSelected(null)
|
||||
await load()
|
||||
toast.success(
|
||||
`Added ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}` +
|
||||
(nested ? ' (nested in the host)' : ''),
|
||||
)
|
||||
const dupes = res.data.skipped_devices ?? []
|
||||
if (dupes.length > 0) {
|
||||
const names = dupes.slice(0, 3).map((d) => d.label).join(', ')
|
||||
const more = dupes.length > 3 ? ` +${dupes.length - 3} more` : ''
|
||||
toast.info(`${dupes.length} already on this canvas, skipped: ${names}${more}`)
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to add the Proxmox host to the canvas')
|
||||
}
|
||||
}
|
||||
|
||||
const goToExistingNode = (nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
@@ -402,27 +561,12 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
approvedDevices.forEach((d, i) => {
|
||||
const nodeId = deviceToNode[d.id]
|
||||
if (!nodeId) return
|
||||
const type = deviceType(d) ?? 'generic'
|
||||
const zwave = isZwaveType(type)
|
||||
const wireless = isZigbeeType(type) || zwave
|
||||
const { type, data } = inventoryNodeData(d)
|
||||
addNode({
|
||||
id: nodeId,
|
||||
type,
|
||||
position: { x: origin.x + (i % 4) * 160, y: origin.y + Math.floor(i / 4) * 100 },
|
||||
data: {
|
||||
label: deviceLabel(d),
|
||||
type,
|
||||
ip: d.ip ?? undefined,
|
||||
mac: d.mac ?? undefined,
|
||||
hostname: d.hostname ?? undefined,
|
||||
status: wireless ? ('online' as const) : ('unknown' as const),
|
||||
services: (d.services ?? []) as ServiceInfo[],
|
||||
properties: zwave
|
||||
? buildZwaveProperties(d)
|
||||
: isZigbeeType(type)
|
||||
? buildZigbeeProperties(d)
|
||||
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
|
||||
},
|
||||
data,
|
||||
})
|
||||
})
|
||||
injectAutoEdges(res.data.edges)
|
||||
@@ -432,7 +576,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
setSelectedIds(new Set())
|
||||
await load()
|
||||
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||
toast.success(`Put ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''} in the canvas${linkExtra}`)
|
||||
// Bulk can't prompt per-device, so report the ones already on this canvas
|
||||
// (skipped as duplicates) instead of silently dropping them.
|
||||
const dupes = res.data.skipped_devices ?? []
|
||||
@@ -449,6 +593,27 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
}
|
||||
}
|
||||
|
||||
const handleMerge = async (winnerId: string) => {
|
||||
const ids = (mergePrompt ?? []).map((d) => d.id).filter((id) => id !== winnerId)
|
||||
if (ids.length === 0) return
|
||||
setMerging(true)
|
||||
try {
|
||||
await scanApi.merge(winnerId, ids)
|
||||
setMergePrompt(null)
|
||||
setSelectedIds(new Set())
|
||||
// Reload rather than patch: a merge moves canvas links around, so
|
||||
// `canvas_count` on the survivor is only right coming from the server.
|
||||
await load()
|
||||
toast.success(`Merged ${ids.length + 1} devices into one`, {
|
||||
description: 'Facts, canvas nodes, rack mounts and documents were kept.',
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to merge devices')
|
||||
} finally {
|
||||
setMerging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkHide = async () => {
|
||||
const ids = [...selectedIds]
|
||||
if (ids.length === 0) return
|
||||
@@ -711,6 +876,14 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
Clear
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setMergePrompt(devices.filter((d) => selectedIds.has(d.id)))}
|
||||
disabled={selectedIds.size < 2}
|
||||
title="Fold the selected rows into one device"
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#00d4ff]/20 text-[#00d4ff] hover:bg-[#00d4ff]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Merge ({selectedIds.size})
|
||||
</button>
|
||||
{statusFilter === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
@@ -718,7 +891,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
disabled={selectedIds.size === 0}
|
||||
className="text-xs px-3 py-1.5 rounded bg-[#39d353]/20 text-[#39d353] hover:bg-[#39d353]/30 disabled:opacity-40 font-medium transition-colors"
|
||||
>
|
||||
Approve ({selectedIds.size})
|
||||
Put in current Canvas ({selectedIds.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBulkHide}
|
||||
@@ -799,6 +972,13 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
<InventoryDeviceModal
|
||||
device={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onOpenDocumentation={
|
||||
onOpenDocumentation &&
|
||||
((deviceId, label) => {
|
||||
setSelected(null)
|
||||
onOpenDocumentation(deviceId, label)
|
||||
})
|
||||
}
|
||||
onApprove={handleApprove}
|
||||
onHide={handleHide}
|
||||
onIgnore={handleIgnore}
|
||||
@@ -809,6 +989,22 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
|
||||
setSelected(saved)
|
||||
}}
|
||||
/>
|
||||
|
||||
<MergeDevicesModal
|
||||
open={mergePrompt !== null}
|
||||
devices={mergePrompt ?? []}
|
||||
busy={merging}
|
||||
onCancel={() => setMergePrompt(null)}
|
||||
onConfirm={handleMerge}
|
||||
/>
|
||||
|
||||
<ProxmoxApproveModal
|
||||
open={proxmoxPrompt !== null}
|
||||
host={proxmoxPrompt?.host ?? null}
|
||||
guests={proxmoxPrompt?.children ?? []}
|
||||
onCancel={() => setProxmoxPrompt(null)}
|
||||
onConfirm={handleProxmoxConfirm}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export type LabelPosition = 'inside' | 'outside'
|
||||
|
||||
export interface GroupRectFormData {
|
||||
label: string
|
||||
/** What the zone is for. Saved on the node, not on any inventory row. */
|
||||
description: string
|
||||
font: string
|
||||
text_color: string
|
||||
text_position: TextPosition
|
||||
@@ -60,6 +62,7 @@ const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||
|
||||
const DEFAULT_FORM: GroupRectFormData = {
|
||||
label: '',
|
||||
description: '',
|
||||
font: 'inter',
|
||||
text_color: '#e6edf3',
|
||||
text_position: 'top-left',
|
||||
@@ -178,6 +181,19 @@ export function GroupRectModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground" htmlFor="zone-description">Description</Label>
|
||||
<textarea
|
||||
id="zone-description"
|
||||
value={form.description}
|
||||
onChange={(e) => set('description', e.target.value)}
|
||||
placeholder="What is in this zone, and what it is for…"
|
||||
rows={3}
|
||||
className={`bg-[#21262d] border border-[#30363d] text-sm resize-y px-2 py-1.5 text-foreground placeholder:text-muted-foreground/40 focus:outline-none focus:border-[#00d4ff]/50 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Font</Label>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
Copy,
|
||||
Factory,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Radar,
|
||||
Server,
|
||||
StickyNote,
|
||||
Tags,
|
||||
X,
|
||||
@@ -41,6 +43,8 @@ import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { PropertyList } from '@/components/common/PropertyList'
|
||||
import { DeviceFaceplateEditor } from '@/rack/components/DeviceFaceplateEditor'
|
||||
import { emptyRackModel, type DeviceRackModel } from '@/rack/deviceRackModel'
|
||||
import { ServiceModal } from './ServiceModal'
|
||||
import { DeepScanModal } from './DeepScanModal'
|
||||
import { scanApi } from '@/api/client'
|
||||
@@ -54,7 +58,7 @@ import { DEVICE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||
import { countPorts } from '@/utils/portSpec'
|
||||
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
|
||||
import { NODE_TYPE_LABELS, type CheckMethod, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
|
||||
import { NODE_TYPE_LABELS, type CheckMethod, type DeviceStatus, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
|
||||
import modalStyles from './modal-interactive.module.css'
|
||||
|
||||
// Home is `@/types` now — re-exported because most call sites import it here.
|
||||
@@ -68,6 +72,9 @@ interface InventoryDeviceModalProps {
|
||||
onIgnore: (device: InventoryEntry) => void
|
||||
/** Called with the saved row after an edit, so the list can refresh in place. */
|
||||
onSaved?: (device: InventoryEntry) => void
|
||||
/** Opens the Documentation section on this device, writing the document
|
||||
* first if it has none. Absent wherever there is no backend. */
|
||||
onOpenDocumentation?: (deviceId: string, label: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
@@ -257,14 +264,39 @@ function toForm(d: InventoryEntry): EditForm {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The device's rack modelisation as the editor wants it, or null when the device
|
||||
* has never been racked.
|
||||
*
|
||||
* `rack_faceplate_id` is the flag; the size columns beside it may still be NULL
|
||||
* on a row written before they were recorded, and the plate's own defaults fill
|
||||
* those in.
|
||||
*/
|
||||
function toRackModel(d: InventoryEntry): DeviceRackModel | null {
|
||||
if (!d.rack_faceplate_id) return null
|
||||
const base = emptyRackModel(d.rack_faceplate_id)
|
||||
return {
|
||||
...base,
|
||||
uHeight: d.rack_u_height ?? base.uHeight,
|
||||
colSpan: d.rack_col_span ?? base.colSpan,
|
||||
color: d.rack_color ?? null,
|
||||
ports: d.rack_ports ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
const nullable = (v: string) => (v.trim() === '' ? null : v.trim())
|
||||
const numeric = (v: string) => (v.trim() === '' ? null : Number(v))
|
||||
|
||||
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore, onSaved }: InventoryDeviceModalProps) {
|
||||
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore, onSaved, onOpenDocumentation }: InventoryDeviceModalProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [form, setForm] = useState<EditForm>(() => (device ? toForm(device) : toForm({} as InventoryEntry)))
|
||||
const [properties, setProperties] = useState<NodeProperty[]>(device?.properties ?? [])
|
||||
const [services, setServices] = useState<ServiceInfo[]>(device?.services ?? [])
|
||||
// The device's front panel. Null for a device no rack has ever modelled —
|
||||
// there is then nothing to draw and nothing to save.
|
||||
const [rackModel, setRackModel] = useState<DeviceRackModel | null>(
|
||||
device ? toRackModel(device) : null,
|
||||
)
|
||||
const [svcModal, setSvcModal] = useState<{ index: number | null; form?: ServiceFormData } | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// The id of the deep rescan this modal started, while it is still running.
|
||||
@@ -299,6 +331,7 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
setForm(toForm(d))
|
||||
setProperties(d.properties ?? [])
|
||||
setServices(d.services ?? [])
|
||||
setRackModel(toRackModel(d))
|
||||
setEditing(false)
|
||||
setSvcModal(null)
|
||||
setRescanRunId(null)
|
||||
@@ -361,6 +394,12 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
const rackOnly = isRackDevice(device)
|
||||
const sources = orderedSources(device)
|
||||
const live = LIVE_META[device.status_live ?? 'unknown'] ?? LIVE_META.unknown
|
||||
// The faceplate's LED speaks the rack's three states, not the checker's four:
|
||||
// "checking" is a moment, not a colour a plate carries.
|
||||
const plateStatus: DeviceStatus =
|
||||
device.status_live === 'online' || device.status_live === 'offline'
|
||||
? device.status_live
|
||||
: 'unknown'
|
||||
const titleLabel = device.label ?? device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
|
||||
|
||||
const set = <K extends keyof EditForm>(key: K, value: EditForm[K]) => setForm((f) => ({ ...f, [key]: value }))
|
||||
@@ -374,6 +413,7 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
setForm(toForm(device))
|
||||
setProperties(device.properties ?? [])
|
||||
setServices(device.services ?? [])
|
||||
setRackModel(toRackModel(device))
|
||||
}
|
||||
|
||||
const handleRescan = async (ports: string) => {
|
||||
@@ -442,6 +482,17 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
check_target: nullable(form.check_target),
|
||||
properties,
|
||||
services,
|
||||
// Only for a device that has a front panel: sending these for one that
|
||||
// has none would model every device the user ever edits.
|
||||
...(rackModel
|
||||
? {
|
||||
rack_faceplate_id: rackModel.faceplateId,
|
||||
rack_u_height: rackModel.uHeight,
|
||||
rack_col_span: rackModel.colSpan,
|
||||
rack_color: rackModel.color,
|
||||
rack_ports: rackModel.ports,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
// Push the row straight into the canvas on screen. Its nodes carry a copy
|
||||
// of these facts, loaded before this edit — left stale, the next canvas
|
||||
@@ -508,14 +559,27 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
</div>
|
||||
</div>
|
||||
{!editing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`shrink-0 gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Pencil size={13} /> Edit
|
||||
</Button>
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
{onOpenDocumentation && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
|
||||
onClick={() => onOpenDocumentation(device.id, titleLabel)}
|
||||
title="Open this device's documentation page"
|
||||
>
|
||||
<BookOpen size={13} /> Documentation
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className={`gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
<Pencil size={13} /> Edit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
@@ -827,6 +891,24 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Full width, under the columns: the plate is drawn at rack
|
||||
proportions and needs the room. Only a device some rack has
|
||||
modelled has one — the row owns it, so it is edited here as well as
|
||||
from a mount. */}
|
||||
{rackModel && (
|
||||
<div className="mt-4">
|
||||
<Section title="Rack faceplate" icon={Server}>
|
||||
<DeviceFaceplateEditor
|
||||
value={rackModel}
|
||||
label={device.label || device.hostname || device.ip || 'device'}
|
||||
status={plateStatus}
|
||||
editable={editing}
|
||||
onChange={setRackModel}
|
||||
/>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Footer: lifecycle in view, form controls in edit ── */}
|
||||
@@ -870,15 +952,15 @@ export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgn
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{/* Rack gear is mounted from a rack canvas, never approved onto a
|
||||
logical one — so it gets no Approve button at all. */}
|
||||
{/* Rack gear is mounted from a rack canvas, never placed on a
|
||||
logical one — so it gets no canvas button at all. */}
|
||||
{!rackOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
|
||||
onClick={handleApprove}
|
||||
>
|
||||
Approve
|
||||
Put in current Canvas
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useState } from 'react'
|
||||
import { Merge, Layers } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
import { deviceName, factCount, suggestWinner } from '@/utils/mergeWinner'
|
||||
|
||||
const ACCENT = '#00d4ff'
|
||||
|
||||
interface MergeDevicesModalProps {
|
||||
open: boolean
|
||||
/** The rows the user selected in the inventory. Two or more. */
|
||||
devices: InventoryEntry[]
|
||||
onCancel: () => void
|
||||
onConfirm: (winnerId: string) => void
|
||||
/** True while the request is in flight — the dialog stays up, disabled. */
|
||||
busy?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before several Device Inventory rows are folded into one.
|
||||
*
|
||||
* The inventory only matches devices when a row is created, so two rows for one
|
||||
* host — minted before either carried the address that would have matched them
|
||||
* — never converge on their own. This is where the user says they are the same
|
||||
* machine and picks which row survives.
|
||||
*
|
||||
* Nothing is thrown away by the merge itself: the survivor keeps every fact it
|
||||
* has and fills its gaps from the others, and canvas nodes, rack mounts and
|
||||
* documents are re-pointed at it. The one thing the user must choose is which
|
||||
* id (and so which of two *conflicting* values) stands.
|
||||
*/
|
||||
export function MergeDevicesModal({ open, devices, onCancel, onConfirm, busy = false }: MergeDevicesModalProps) {
|
||||
// Only the user's override is state. The winner is otherwise derived, so a
|
||||
// choice held over from a previous selection — a row that no longer exists —
|
||||
// falls back to the suggestion instead of pointing at nothing.
|
||||
const [picked, setPicked] = useState<string | undefined>(undefined)
|
||||
|
||||
if (devices.length < 2) return null
|
||||
|
||||
const winnerId = devices.some((d) => d.id === picked) ? picked : suggestWinner(devices)
|
||||
const winner = devices.find((d) => d.id === winnerId)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Merge size={16} style={{ color: ACCENT }} />
|
||||
Merge {devices.length} devices into one
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Keep which row? The others fold into it — their facts fill its gaps, and every
|
||||
canvas node, rack mount and document they own moves across.
|
||||
</p>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto space-y-1.5">
|
||||
{devices.map((d) => (
|
||||
<label
|
||||
key={d.id}
|
||||
className={`flex items-start gap-2 rounded-md border px-3 py-2 text-xs cursor-pointer transition-colors ${
|
||||
d.id === winnerId
|
||||
? 'border-[#00d4ff]/50 bg-[#00d4ff]/10'
|
||||
: 'border-border bg-[#0d1117]/60 hover:border-muted-foreground/40'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="merge-winner"
|
||||
checked={d.id === winnerId}
|
||||
onChange={() => setPicked(d.id)}
|
||||
className="mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-foreground font-medium truncate">{deviceName(d)}</span>
|
||||
{(d.canvas_count ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-[10px] text-[#00d4ff] shrink-0">
|
||||
<Layers size={10} />
|
||||
{d.canvas_count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground font-mono break-all">
|
||||
{[d.ip, d.mac, d.ieee_address].filter(Boolean).join(' · ') || 'no address'}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
{factCount(d)} fact{factCount(d) !== 1 ? 's' : ''}
|
||||
{d.services?.length ? ` · ${d.services.length} service${d.services.length !== 1 ? 's' : ''}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{winner
|
||||
? `Merging cannot be undone. ${devices.length - 1} row${devices.length - 1 !== 1 ? 's' : ''} will be deleted once their facts and links are on ${deviceName(winner)}.`
|
||||
: 'Pick the row to keep.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => winnerId && onConfirm(winnerId)}
|
||||
disabled={!winnerId || busy}
|
||||
style={{ backgroundColor: `${ACCENT}33`, color: ACCENT }}
|
||||
>
|
||||
{busy ? 'Merging…' : `Merge into ${winner ? deviceName(winner) : 'this row'}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug,
|
||||
import { IconPickerPanel } from './IconPickerPanel'
|
||||
import { MAX_HANDLES, MIN_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||
import { isValidParentNode } from '@/utils/virtualEdgeParent'
|
||||
import { NODE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
|
||||
import { NODE_TYPE_GROUPS, isFurnitureType } from '@/utils/nodeTypeGroups'
|
||||
|
||||
// Maps a side to its per-type default field on NodeTypeStyle.
|
||||
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
|
||||
@@ -134,6 +134,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||
const [iconPickerOpen, setIconPickerOpen] = useState(false)
|
||||
const [labelError, setLabelError] = useState(false)
|
||||
// Canvas furniture: an annotation, not a device. It has no inventory row, so
|
||||
// its text is a `description` on the node rather than the device's `notes`.
|
||||
const isFurniture = isFurnitureType(form.type)
|
||||
const resolvedNodeColors = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
|
||||
const showServicesEnabled = form.custom_colors?.show_services === true
|
||||
const hasAppearanceOverrides = Boolean(
|
||||
@@ -424,13 +427,15 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* Notes */}
|
||||
{/* Notes — or, for canvas furniture, its description.
|
||||
Furniture draws no device, so it has no inventory row to keep
|
||||
`notes` on; its text lives on the node's own `description`. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||
<Label className="text-xs text-muted-foreground">{isFurniture ? 'Description' : 'Notes'}</Label>
|
||||
<Textarea
|
||||
value={form.notes ?? ''}
|
||||
onChange={(e) => set('notes', e.target.value)}
|
||||
placeholder="Optional notes"
|
||||
value={(isFurniture ? form.description : form.notes) ?? ''}
|
||||
onChange={(e) => set(isFurniture ? 'description' : 'notes', e.target.value)}
|
||||
placeholder={isFurniture ? 'What this is for' : 'Optional notes'}
|
||||
rows={3}
|
||||
className={`bg-[#21262d] border-[#30363d] text-sm resize-y min-h-16 ${modalStyles['modal-radius']}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useState } from 'react'
|
||||
import { Server, Box, Container, Plus } from 'lucide-react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { InventoryEntry } from '@/types'
|
||||
import type { ProxmoxCanvasMode } from '@/components/proxmox/types'
|
||||
|
||||
const ACCENT = '#e57000'
|
||||
|
||||
export interface ProxmoxApproveChoice {
|
||||
/** Guest inventory ids to place alongside the host. Empty = host only. */
|
||||
childIds: string[]
|
||||
mode: ProxmoxCanvasMode
|
||||
}
|
||||
|
||||
interface ProxmoxApproveModalProps {
|
||||
open: boolean
|
||||
host: InventoryEntry | null
|
||||
/** Guests the host runs, from `scanApi.proxmoxChildren`. Deliberately not
|
||||
* named `children` — React would treat the array as renderable child nodes. */
|
||||
guests: InventoryEntry[]
|
||||
onCancel: () => void
|
||||
onConfirm: (choice: ProxmoxApproveChoice) => void
|
||||
}
|
||||
|
||||
function label(d: InventoryEntry): string {
|
||||
return d.label ?? d.friendly_name ?? d.hostname ?? d.ip ?? d.ieee_address ?? 'device'
|
||||
}
|
||||
|
||||
/**
|
||||
* Asked before a Proxmox host from the Device Inventory reaches a canvas: bring
|
||||
* its VMs/LXCs along, and if so draw them nested inside the host
|
||||
* (`container_mode`) or as separate nodes joined by virtual edges.
|
||||
*
|
||||
* Only shown when the host actually has guests in the inventory — a host with
|
||||
* none is approved straight away.
|
||||
*/
|
||||
export function ProxmoxApproveModal({
|
||||
open,
|
||||
host,
|
||||
guests,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: ProxmoxApproveModalProps) {
|
||||
const [includeChildren, setIncludeChildren] = useState(true)
|
||||
const [mode, setMode] = useState<ProxmoxCanvasMode>('container')
|
||||
|
||||
if (!host) return null
|
||||
|
||||
const confirm = () => {
|
||||
onConfirm({
|
||||
childIds: includeChildren ? guests.map((c) => c.id) : [],
|
||||
mode: includeChildren ? mode : 'linked',
|
||||
})
|
||||
// Next host starts from the defaults again.
|
||||
setIncludeChildren(true)
|
||||
setMode('container')
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onCancel()}>
|
||||
<DialogContent className="bg-[#161b22] border-border max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-foreground flex items-center gap-2">
|
||||
<Server size={16} style={{ color: ACCENT }} />
|
||||
Add {label(host)} to canvas
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-1">
|
||||
<label className="flex items-start gap-2 rounded-md border border-border bg-[#0d1117]/60 px-3 py-2.5 text-xs cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeChildren}
|
||||
onChange={(e) => setIncludeChildren(e.target.checked)}
|
||||
className="w-3 h-3 mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-foreground">
|
||||
Also add its {guests.length} guest{guests.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
VMs and LXC containers this host runs, as recorded by the Proxmox import.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{includeChildren && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-[#0d1117]/60 px-3 py-2.5">
|
||||
<span className="block text-xs text-muted-foreground">Draw the guests as</span>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="proxmox-approve-mode"
|
||||
checked={mode === 'container'}
|
||||
onChange={() => setMode('container')}
|
||||
className="mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<span>
|
||||
Nested inside the host
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Container mode — the host becomes a box holding its guests.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="proxmox-approve-mode"
|
||||
checked={mode === 'linked'}
|
||||
onChange={() => setMode('linked')}
|
||||
className="mt-0.5 cursor-pointer shrink-0"
|
||||
style={{ accentColor: ACCENT }}
|
||||
/>
|
||||
<span>
|
||||
Separate nodes linked to the host
|
||||
<span className="block text-[11px] text-muted-foreground">
|
||||
Guests sit beside the host, joined by virtual edges.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{includeChildren && guests.length > 0 && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-1">
|
||||
{guests.map((c) => {
|
||||
const type = (c.type ?? c.suggested_type) ?? 'vm'
|
||||
const Icon = type === 'lxc' ? Container : Box
|
||||
const color = type === 'lxc' ? '#39d353' : '#00d4ff'
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md bg-[#21262d] text-xs"
|
||||
>
|
||||
<Icon size={12} style={{ color }} className="shrink-0" />
|
||||
<span className="text-foreground truncate">{label(c)}</span>
|
||||
{c.ip && (
|
||||
<span className="font-mono text-[10px] text-muted-foreground truncate ml-auto">
|
||||
{c.ip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
onClick={confirm}
|
||||
style={{ background: ACCENT, color: '#0d1117' }}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} />
|
||||
Add {includeChildren ? guests.length + 1 : 1} to canvas
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,29 @@ import { useState, useCallback, useEffect } from 'react'
|
||||
import { useReactFlow } from '@xyflow/react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { scanApi } from '@/api/client'
|
||||
import { documentsApi, scanApi } from '@/api/client'
|
||||
import type { DocSearchHit } from '@/documentation/types'
|
||||
import type { InventoryEntry } from '@/components/modals/InventoryDeviceModal'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
/** Long enough that a stray keystroke does not hit the server. */
|
||||
const DOC_SEARCH_DEBOUNCE = 250
|
||||
|
||||
interface SearchModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onOpenInventory: (deviceId: string) => void
|
||||
/** Omitted in standalone, where there are no documents to open. */
|
||||
onOpenDocument?: (docId: string) => void
|
||||
}
|
||||
|
||||
export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps) {
|
||||
export function SearchModal({ open, onClose, onOpenInventory, onOpenDocument }: SearchModalProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [inventoryDevices, setInventoryEntrys] = useState<InventoryEntry[]>([])
|
||||
// Kept with the query it answered, so a stale answer is never shown against a
|
||||
// newer query and no effect has to clear it.
|
||||
const [docHits, setDocHits] = useState<{ query: string; hits: DocSearchHit[] }>({ query: '', hits: [] })
|
||||
const nodes = useCanvasStore((s) => s.nodes)
|
||||
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||
const { fitView } = useReactFlow()
|
||||
@@ -23,9 +34,33 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
scanApi.pending().then((res) => setInventoryEntrys(res.data)).catch(() => {})
|
||||
}, [open])
|
||||
|
||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
const q = query.toLowerCase()
|
||||
|
||||
// Documents are not in memory — the tree only ever holds their metadata, and
|
||||
// the body is what makes a search worth running — so this one goes to the
|
||||
// server's index rather than filtering a local list.
|
||||
useEffect(() => {
|
||||
if (!open || STANDALONE || !onOpenDocument) return
|
||||
const needle = query.trim()
|
||||
if (needle.length < 2) return
|
||||
let live = true
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const { data } = await documentsApi.search(needle, 5)
|
||||
if (live) setDocHits({ query: needle, hits: data.hits })
|
||||
} catch {
|
||||
// The rest of the modal still works without the index.
|
||||
if (live) setDocHits({ query: needle, hits: [] })
|
||||
}
|
||||
}, DOC_SEARCH_DEBOUNCE)
|
||||
return () => {
|
||||
live = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [open, query, onOpenDocument])
|
||||
|
||||
const searchable = nodes.filter((n) => n.data.type !== 'groupRect')
|
||||
|
||||
const nodeResults = q.length === 0 ? [] : searchable.filter((n) =>
|
||||
n.data.label?.toLowerCase().includes(q) ||
|
||||
n.data.ip?.toLowerCase().includes(q) ||
|
||||
@@ -43,7 +78,9 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
)
|
||||
).slice(0, 4)
|
||||
|
||||
const totalResults = nodeResults.length + pendingResults.length
|
||||
const documentResults = docHits.query && docHits.query === query.trim() ? docHits.hits : []
|
||||
|
||||
const totalResults = nodeResults.length + pendingResults.length + documentResults.length
|
||||
|
||||
const handleSelectNode = useCallback((nodeId: string) => {
|
||||
setSelectedNode(nodeId)
|
||||
@@ -58,6 +95,12 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
setQuery('')
|
||||
}, [onOpenInventory, onClose])
|
||||
|
||||
const handleSelectDocument = useCallback((docId: string) => {
|
||||
onOpenDocument?.(docId)
|
||||
onClose()
|
||||
setQuery('')
|
||||
}, [onOpenDocument, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -72,12 +115,14 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search nodes, pending devices by IP or service…"
|
||||
placeholder="Search nodes, devices and documents…"
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { onClose(); setQuery('') }
|
||||
// Enter takes the first result in the order they are listed.
|
||||
if (e.key === 'Enter' && nodeResults.length > 0) handleSelectNode(nodeResults[0].id)
|
||||
if (e.key === 'Enter' && nodeResults.length === 0 && pendingResults.length > 0) handleSelectInventoryDevice(pendingResults[0].id)
|
||||
else if (e.key === 'Enter' && pendingResults.length > 0) handleSelectInventoryDevice(pendingResults[0].id)
|
||||
else if (e.key === 'Enter' && documentResults.length > 0) handleSelectDocument(documentResults[0].doc_id)
|
||||
}}
|
||||
/>
|
||||
<kbd className="text-[10px] text-muted-foreground border border-border rounded px-1">ESC</kbd>
|
||||
@@ -117,6 +162,30 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{documentResults.length > 0 && nodeResults.length + pendingResults.length > 0 && (
|
||||
<li className="px-4 py-1">
|
||||
<div className="h-px bg-border" />
|
||||
</li>
|
||||
)}
|
||||
{documentResults.map((hit) => (
|
||||
<li
|
||||
key={hit.doc_id}
|
||||
className="flex items-center gap-3 px-4 py-2 hover:bg-[#21262d] cursor-pointer"
|
||||
onClick={() => handleSelectDocument(hit.doc_id)}
|
||||
>
|
||||
<span className="text-xs font-mono text-[#a855f7] w-16 shrink-0">
|
||||
{hit.kind === 'device' ? 'doc·dev' : 'doc'}
|
||||
</span>
|
||||
<span className="text-sm text-foreground font-medium flex-1 truncate">{hit.title}</span>
|
||||
{/* The snippet is the line the match sits on; it is the only
|
||||
reason a body search beats scanning the tree by eye. */}
|
||||
{hit.snippet && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 max-w-[45%] truncate">
|
||||
{hit.snippet}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
@@ -125,7 +194,9 @@ export function SearchModal({ open, onClose, onOpenInventory }: SearchModalProps
|
||||
)}
|
||||
|
||||
{q.length === 0 && (
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">Type to search nodes and pending devices…</p>
|
||||
<p className="px-4 py-3 text-xs text-muted-foreground">
|
||||
Type to search nodes, pending devices{onOpenDocument && !STANDALONE ? ' and documents' : ''}…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,8 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se
|
||||
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
Overrides the node host for this service only.
|
||||
Overrides the node host for this service only. The port above is
|
||||
dropped — add one here (host:port) if the URL needs it.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user