Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 701c9c5bb9 |
@@ -1,10 +1,6 @@
|
|||||||
# Backend - server-side only (NEVER commit .env)
|
# Backend - server-side only (NEVER commit .env)
|
||||||
SECRET_KEY=change_me_in_production
|
SECRET_KEY=change_me_in_production
|
||||||
SQLITE_PATH=./data/homelab.db
|
SQLITE_PATH=./data/homelab.db
|
||||||
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
|
|
||||||
# which sits on the same persistent volume as the DB.
|
|
||||||
# UPLOAD_DIR=./data/uploads
|
|
||||||
# Set this to the URL(s) you use to access Homelable in your browser.
|
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
|
||||||
# Auth — default credentials: admin / admin
|
# Auth — default credentials: admin / admin
|
||||||
@@ -17,15 +13,6 @@ AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG
|
|||||||
# Scanner — JSON array of CIDR ranges to scan
|
# Scanner — JSON array of CIDR ranges to scan
|
||||||
SCANNER_RANGES=["192.168.1.0/24"]
|
SCANNER_RANGES=["192.168.1.0/24"]
|
||||||
|
|
||||||
# Deep scan (optional) — extra nmap port ranges + HTTP probe for service ID on
|
|
||||||
# custom ports. Defaults below are overridable per-scan from the scan dialog.
|
|
||||||
# SCANNER_HTTP_RANGES: JSON array of port specs, each a single port "N" or an
|
|
||||||
# inclusive range "N-M" (1–65535, N <= M). Not CIDRs, not bare ints.
|
|
||||||
# Example: SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
|
||||||
SCANNER_HTTP_RANGES=[]
|
|
||||||
SCANNER_HTTP_PROBE_ENABLED=false
|
|
||||||
SCANNER_HTTP_VERIFY_TLS=false
|
|
||||||
|
|
||||||
# Status checker interval in seconds
|
# Status checker interval in seconds
|
||||||
STATUS_CHECKER_INTERVAL=60
|
STATUS_CHECKER_INTERVAL=60
|
||||||
|
|
||||||
@@ -35,49 +22,3 @@ STATUS_CHECKER_INTERVAL=60
|
|||||||
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
# Generate keys: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
MCP_API_KEY=mcp_sk_changeme
|
MCP_API_KEY=mcp_sk_changeme
|
||||||
MCP_SERVICE_KEY=svc_changeme
|
MCP_SERVICE_KEY=svc_changeme
|
||||||
|
|
||||||
# Live view — read-only public canvas at /view?key=<value>
|
|
||||||
# Off by default. Set to a random secret to enable.
|
|
||||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
# LIVEVIEW_KEY=
|
|
||||||
|
|
||||||
# Gethomepage widget — read-only stats at /api/v1/stats/summary
|
|
||||||
# Off by default. Set to a random secret to enable; clients must send
|
|
||||||
# the same value in the `X-API-Key` header.
|
|
||||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
# HOMEPAGE_API_KEY=
|
|
||||||
|
|
||||||
# Proxmox VE import — pull hosts/VMs/LXC from the Proxmox REST API.
|
|
||||||
# The token is a credential: kept in memory only, never written to disk by the
|
|
||||||
# app, never returned by any API. Create it under Datacenter → Permissions →
|
|
||||||
# API Tokens and grant the read-only PVEAuditor role at path "/".
|
|
||||||
# Token id format: user@realm!tokenname (e.g. root@pam!homelable).
|
|
||||||
# Required only for auto-sync; one-off imports can pass the token in the dialog.
|
|
||||||
# PROXMOX_TOKEN_ID=root@pam!homelable
|
|
||||||
# PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
|
||||||
# PROXMOX_HOST=192.168.1.10
|
|
||||||
# PROXMOX_PORT=8006
|
|
||||||
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
|
|
||||||
|
|
||||||
# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule.
|
|
||||||
# MQTT credentials are secrets: kept in memory only, never written to disk by
|
|
||||||
# the app, never returned by any API. Manual imports (the Zigbee dialog) are
|
|
||||||
# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now.
|
|
||||||
# Required only for auto-sync; one-off imports pass their config in the dialog.
|
|
||||||
# ZIGBEE_MQTT_HOST=192.168.1.20
|
|
||||||
# ZIGBEE_MQTT_PORT=1883
|
|
||||||
# ZIGBEE_MQTT_USERNAME=mqttuser # optional
|
|
||||||
# ZIGBEE_MQTT_PASSWORD=mqttpass # optional
|
|
||||||
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
|
|
||||||
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
|
||||||
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
|
||||||
|
|
||||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
|
|
||||||
# ZWAVE_MQTT_HOST=192.168.1.20
|
|
||||||
# ZWAVE_MQTT_PORT=1883
|
|
||||||
# ZWAVE_MQTT_USERNAME=mqttuser # optional
|
|
||||||
# ZWAVE_MQTT_PASSWORD=mqttpass # optional
|
|
||||||
# ZWAVE_PREFIX=zwave
|
|
||||||
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
|
|
||||||
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
|
||||||
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
# These are supported funding model platforms
|
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
|
||||||
patreon: # Replace with a single Patreon username
|
|
||||||
open_collective: # Replace with a single Open Collective username
|
|
||||||
ko_fi: pouzor
|
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
|
||||||
liberapay: # Replace with a single Liberapay username
|
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
|
||||||
polar: # Replace with a single Polar username
|
|
||||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
|
||||||
thanks_dev: # Replace with a single thanks.dev username
|
|
||||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
|
||||||
@@ -6,9 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
smoke-and-integration:
|
smoke-and-integration:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -16,21 +16,14 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- image: ghcr.io/pouzor/homelable-backend
|
- image: ghcr.io/pouzor/homelable-backend
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.backend
|
dockerfile: Dockerfile.backend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend
|
- image: ghcr.io/pouzor/homelable-frontend
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: ""
|
build_args: ""
|
||||||
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
- image: ghcr.io/pouzor/homelable-frontend-standalone
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile.frontend
|
dockerfile: Dockerfile.frontend
|
||||||
build_args: "VITE_STANDALONE=true"
|
build_args: "VITE_STANDALONE=true"
|
||||||
- image: ghcr.io/pouzor/homelable-mcp
|
|
||||||
context: ./mcp
|
|
||||||
dockerfile: Dockerfile.mcp
|
|
||||||
build_args: ""
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -62,8 +55,8 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: .
|
||||||
file: ${{ matrix.context }}/${{ matrix.dockerfile }}
|
file: ${{ matrix.dockerfile }}
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-scripts:
|
lint-scripts:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ on:
|
|||||||
schedule:
|
schedule:
|
||||||
- cron: '0 9 * * 1' # Weekly on Monday
|
- cron: '0 9 * * 1' # Weekly on Monday
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
secrets-scan:
|
secrets-scan:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
+1
-8
@@ -1,5 +1,6 @@
|
|||||||
# Claude / project meta — never commit
|
# Claude / project meta — never commit
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
FEATURES.md
|
||||||
.claude/
|
.claude/
|
||||||
_project_specs/
|
_project_specs/
|
||||||
|
|
||||||
@@ -44,15 +45,7 @@ htmlcov/
|
|||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
*.db.back
|
|
||||||
*.db.back-*
|
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
.docker/
|
.docker/
|
||||||
Ideas.md
|
Ideas.md
|
||||||
|
|
||||||
# Local dev/test utilities (never commit)
|
|
||||||
scripts/zwave-mock-gateway.py
|
|
||||||
|
|
||||||
# Docs (local only)
|
|
||||||
docs/database-model.md
|
|
||||||
|
|||||||
-652
@@ -1,652 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to **Homelable** are documented here.
|
|
||||||
|
|
||||||
The format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
||||||
|
|
||||||
## [3.1.1] - 2026-07-19
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- Drag to reorder services in the node detail panel. Thanks @Isilla. (#302)
|
|
||||||
- Getting Started walkthrough tour with first-run canvas detection. (#300)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- Round-trip Show Port Numbers on YAML export/import. (#301)
|
|
||||||
- Render error toasts on a red surface. (#300)
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Rework README header with logo, badges, and nav links. (#300)
|
|
||||||
|
|
||||||
## [3.1.0] - 2026-07-18
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- Drag to reorder node properties. (#296)
|
|
||||||
- Search for nodes and properties. Thanks @Floyddotnet. (#294)
|
|
||||||
- Widen zone modal into two columns. (#293)
|
|
||||||
- Autosave canvas after a configurable inactivity delay (opt-in). Thanks @nicolabottini. (#289)
|
|
||||||
- MCP: `design_id` on approve_device + `delete_design` tool. Thanks @nicolabottini. (#281)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- Scanner: decouple port discovery from version detection. (#295, #277)
|
|
||||||
- Allow undo after Auto Layout and YAML import. (#290)
|
|
||||||
- Move edge waypoints when the connected node or its container is dragged. (#288)
|
|
||||||
- Keep container host height when editing an unrelated field. (#287)
|
|
||||||
- Keep container children in place when editing a host node. Thanks @nicolabottini. (#286)
|
|
||||||
- `list_pending_devices` MCP tool leaked approved/hidden inventory rows. Thanks @MikeSviblov. (#273)
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Fix MCP client transport to http (was sse). (#275)
|
|
||||||
|
|
||||||
## [3.0.0] - 2026-07-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Proxmox VE import: pull hosts, VMs and LXC into inventory, with optional scheduled auto-sync and a manual re-sync button (connection config is now env-only). (#253, #259)
|
|
||||||
- Scheduled auto-sync for Zigbee & Z-Wave imports. (#270)
|
|
||||||
- Floor plan map background, LQI-based edge coloring, and Zigbee path highlighting. Thanks @pranjal-joshi. (#207)
|
|
||||||
- Customizable connection points per node side. (#249)
|
|
||||||
- Configurable edge line style + width per type and per edge, plus selectable endpoint marker shapes. (#250, #252)
|
|
||||||
- Create a new canvas by copying an existing one. (#257)
|
|
||||||
- Prompt on duplicate device instead of silently blocking/merging on approve. (#261)
|
|
||||||
- Active design synced to URL for refresh/share. (#263)
|
|
||||||
- MCP can target a specific design/canvas, create canvases, and auto-position nodes with auto-assigned edge handles. Thanks @nicolabottini. (#266)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Mesh (Zigbee/Z-Wave) import: duplicate-node crash and coordinator now routed to pending. (#247)
|
|
||||||
- Keep mesh/cluster links so edges resolve onto a second canvas. (#254)
|
|
||||||
- Preserve edge connection points in YAML export/import. (#255, #208)
|
|
||||||
- Harden media path handling against path injection. (#256)
|
|
||||||
- Match scanned devices to canvas nodes by ip-token and MAC. (#262, #258)
|
|
||||||
- Record ScanRun for scheduled Proxmox auto-sync. (#269)
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- User-facing FEATURES.md. (#248)
|
|
||||||
- CHANGELOG with full release history. (#264)
|
|
||||||
|
|
||||||
## [2.6.1] - 2026-06-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Z-Wave nodes can be added/edited manually. A Z-Wave group (Controller / Router / End Device) is now in the Add/Edit node type selector, matching Zigbee. Mesh-radio nodes default to no status check.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Standalone (frontend-only) multi-canvas (designs) support, originally merged in #244 after the 2.6.0 tag with no version bump.
|
|
||||||
|
|
||||||
## [2.6.0] - 2026-06-28
|
|
||||||
|
|
||||||
Device Pending is gone — replaced by **Device Inventory**: devices stay in an inventory, can be placed in multiple canvases, rescanned and updated. New **Z-Wave** section: scan, import and store Z-Wave devices like Zigbee.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- SVG export option in the canvas export modal, alongside PNG (#238).
|
|
||||||
- Deep-scan HTTP probe + Device Inventory: scans probe HTTP services and surface a per-device inventory (#222, closes #195).
|
|
||||||
- Z-Wave network scan via the MQTT bridge (#224).
|
|
||||||
- Inventory timestamps on nodes — nodes show when their inventory was last collected (#233).
|
|
||||||
- New nodes drop at the centre of the visible canvas instead of off-screen (#234).
|
|
||||||
- Expanded MAC OUI database: router, switch, AP, NAS and camera vendor prefixes (#220).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Optional white background on export and working downloads in Firefox (#239, closes #165).
|
|
||||||
- MCP-created nodes/edges attach to the active design instead of floating (#237, closes #225).
|
|
||||||
- Stopping a scan interrupts the running nmap range immediately (#236, closes #218).
|
|
||||||
- Auto-layout orders Proxmox children by parent port number (#235).
|
|
||||||
- Copy-to-clipboard for Markdown works on non-secure HTTP origins (#219).
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Documented the `SCANNER_HTTP_RANGES` port-spec format (#232).
|
|
||||||
|
|
||||||
## [2.5.1] - 2026-06-18
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Manually resized nodes — including VM/LXC nodes nested inside a Proxmox container — keep their size on reload instead of snapping back to content-fit.
|
|
||||||
- Node detail panel has a Size section for pixel-exact width/height; fields resync live when dragging a corner handle.
|
|
||||||
- Adding an LXC/VM under a non-container-mode Proxmox node no longer confines it to a tiny box — it stays a free, draggable node.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Resolved high-severity npm audit advisories (`esbuild`, `form-data`, `vite` → 7.3.5).
|
|
||||||
- Bumped `python-multipart` to 0.0.31 (CVE-2026-53538/53539/53540).
|
|
||||||
|
|
||||||
## [2.5.0] - 2026-06-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Scan History modal with run duration, finished timestamp, and kind/status filters (#203).
|
|
||||||
- Container nesting: drag a node onto a container node to nest it; detach/re-parent via an editable container selector (#202).
|
|
||||||
- Editable node groups: add/remove members and edit the group description directly (#200).
|
|
||||||
- Multi-line edge labels (#199).
|
|
||||||
- Per-service status checks with offline colouring (#198).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Reduce status flapping, add IPv6 ping, colour manually-added web services (#198).
|
|
||||||
- Keep non-HTTP services grey instead of red (#198).
|
|
||||||
- Idempotent WebSocket connection removal — release the slot on any error (#198).
|
|
||||||
- Persist group description with Ctrl+S, not only on blur (#200).
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Resolve Dependabot and code-scanning alerts; bump hono to 4.12.21+ (#197).
|
|
||||||
- Bump zeroconf to 0.149.12 for CVE-2026-48045 (#202).
|
|
||||||
|
|
||||||
## [2.4.0] - 2026-06-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Multiple designs (canvases): build and switch between separate diagrams, plus a new electrical-device node type (#177).
|
|
||||||
- Copy & paste nodes between designs (#189).
|
|
||||||
- Switch port numbers (up to 64 ports) plus a new fibre-optic link style (#172).
|
|
||||||
- Read-only Live View link in the header for sharing or wall displays (#185).
|
|
||||||
- Scans keep discovered MAC addresses when a device is approved (#178).
|
|
||||||
- Richer AI/MCP control: assistants can set the full set of node fields on create/update (#180).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Settings moved into a dedicated window for a cleaner sidebar (#188).
|
|
||||||
- "Hide IP" option now lives in Settings and is remembered between sessions (#189).
|
|
||||||
- Smoother link animations for flowing/snake styles (#187).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- "Show Port Numbers" now persists after reload (#191).
|
|
||||||
- The Save button works again (previously only Ctrl/Cmd+S) (#190).
|
|
||||||
- Zigbee re-import correctly brings back already-approved devices (#176).
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Dependency updates: zeroconf, qs, brace-expansion (#175, #182).
|
|
||||||
|
|
||||||
## [2.3.0] - 2026-05-31
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Full node schema over MCP: `create_node` / `update_node` expose the complete backend schema (`os`, `notes`, `mac`, `services`, `cpu_count`, `cpu_model`, `ram_gb`, `disk_gb`, `properties`, …); `get_canvas` round-trips the same fields (#174).
|
|
||||||
- MAC carried onto approved nodes from a scan (#168).
|
|
||||||
- Richer switch & edge modeling: switch port cap raised to 64, port numbers shown, new fibre edge type.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Canvas includes the MAC property on the approved node so it shows immediately after approval (#168).
|
|
||||||
- Zigbee: revive orphaned approved devices on re-import instead of dropping them (#167).
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Bump `zeroconf` 0.131.0 → 0.149.7 for CVE fixes.
|
|
||||||
|
|
||||||
## [2.2.0] - 2026-05-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Laptop & mobile node types with theme-coherent styling (#166).
|
|
||||||
- Collapsible / expandable zones — `collapsed` is now a first-class `NodeData` field with edge rewiring and read-only liveview support (#158).
|
|
||||||
- LXC / bare-metal MCP install script `lxc-mcp-install.sh` with env-var overrides and repo-clone fallback; MCP image published to GHCR (#163).
|
|
||||||
|
|
||||||
## [2.1.1] - 2026-05-16
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- LiveView: nest Docker container children under their host node; apply saved theme & custom style on load.
|
|
||||||
|
|
||||||
## [2.1.0] - 2026-05-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Draggable edge endpoints — reconnect either end of an edge to another node/handle; Proxmox containers gain snap points (#150).
|
|
||||||
- Docker containers can live inside VMs, Proxmox hosts or LXCs; parent auto-cleaned on type change (#153, #154).
|
|
||||||
- Group node side handles (top/right/bottom/left) for proper edge attachment (#152).
|
|
||||||
- Multiple IPs per node — paste several IPs separated by comma/space/newline, each clickable individually (#136).
|
|
||||||
- Zigbee device properties (IEEE, Vendor, Model, LQI) auto-populated on approve / re-import (#148).
|
|
||||||
- Homepage widget stats endpoint: new `/stats` API for gethomepage integration (closes #131, #149).
|
|
||||||
- Zone modal polish: centered opacity thumb, fixed font casing, full keyboard + ARIA support (#106).
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Dropped `passlib` in favour of direct `bcrypt`; status-check targets reject CLI-flag injection.
|
|
||||||
|
|
||||||
## [2.0.3] - 2026-05-13
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Remove check method for Zigbee nodes.
|
|
||||||
|
|
||||||
## [2.0.2] - 2026-05-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Activated dashboardicons icons — brand-new icons selectable for nodes.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Various modal fixes.
|
|
||||||
|
|
||||||
## [2.0.1] - 2026-05-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- New Zigbee nodes available in the new/edit node modal.
|
|
||||||
|
|
||||||
## [2.0.0] - 2026-05-11
|
|
||||||
|
|
||||||
A major milestone: Zigbee2MQTT integration, a new pending-devices modal, text nodes and alignment guides.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Zigbee2MQTT integration** (thanks @pranjal-joshi): import a full Zigbee network map from Z2M as a background scan run; three new node types (Coordinator, Router, End Device); MQTT TLS with optional cert-verify skip; imported devices flow through the pending section with edge persistence.
|
|
||||||
- **New pending devices modal**: full-screen grid with cards, filters (IP / Zigbee / all), search, multi-select and bulk restore.
|
|
||||||
- **Text node type** for free-form annotations (#138).
|
|
||||||
- **Alignment guides + snap** while dragging nodes, built on `OnNodeDrag` (#139).
|
|
||||||
- **Per-node services toggle** to show/hide the services list on the node (thanks @findthelorax) (#107).
|
|
||||||
- Zigbee node types exposed in the Custom Style editor.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Status checker: ms timeout for ping on macOS.
|
|
||||||
- Scan: default `check_method='ping'` for approved devices.
|
|
||||||
- Detail panel: handle non-Z timezone offsets in Last Seen.
|
|
||||||
- Groups: preserve children + size on edit; fix status WebSocket proxy.
|
|
||||||
- Pending: drop dangling `onNodeApproved` call; null-safe pending IP in SearchModal.
|
|
||||||
- Text node: persist text in `label` across reloads.
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Bump `fast-uri` (GHSA-q3j6-qgpj-74h6, GHSA-v39h-62p7-jpjc), `axios` ^1.15.2, `python-multipart`.
|
|
||||||
- Sanitize MQTT error messages to prevent credential leakage.
|
|
||||||
|
|
||||||
## [1.13.0] - 2026-05-03
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- New **Firewall** node type under Add Node → Hardware, with distinct flame icon and red accent.
|
|
||||||
- Up to **48 bottom connection points** per node (slider, previously capped at 4); the node card grows wider as handles are added.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Sidebar no longer freezes on Scan History after starting a scan.
|
|
||||||
- Delete confirmation respects Cancel — clicking Cancel keeps the modal open.
|
|
||||||
|
|
||||||
## [1.12.0] - 2026-04-24
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Custom Style Editor**: per-node-type border/background/icon colour + opacity, per-edge colour/opacity/path/animation, default size per type, "Apply to existing nodes" and "Apply All to Canvas"; saved with the canvas.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Accessibility overhaul: consistent hover/focus borders, pointer cursors, keyboard navigation and Enter-to-apply across all modals (#108).
|
|
||||||
- IP address hint moved below the field; hover border colour toned down.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Edge labels and the `+` waypoint handle stay on the routed path when waypoints are used (#94).
|
|
||||||
- Edge type select displays its full label (e.g. "IoT / Zigbee") instead of the raw value.
|
|
||||||
- Status dot overlapping IP address in node cards.
|
|
||||||
- Canvas style modal layout on smaller screens.
|
|
||||||
- MCP session manager routing.
|
|
||||||
|
|
||||||
## [1.11.0] - 2026-04-23
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Docker Host** node type with container mode support (visual group for containers).
|
|
||||||
- **Docker Container** node type, nestable inside a Docker Host.
|
|
||||||
- Container mode now works for `docker_host`, `vm` and `lxc` (was Proxmox-only).
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Service badges prioritise the service name; path truncates gracefully with a hover tooltip.
|
|
||||||
- Node resizer handle hit area enlarged (8px → 16px).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Container mode not persisting after save/reload for non-Proxmox types.
|
|
||||||
- Container mode toggle having no visual effect on `docker_host` nodes.
|
|
||||||
- Status dot overlapping node content; consistent top-right placement.
|
|
||||||
- Missing icon/label gap in ProxmoxGroupNode container header.
|
|
||||||
- IP shown unmasked in ProxmoxGroupNode when hide-IP is enabled.
|
|
||||||
|
|
||||||
## [1.10.2] - 2026-04-21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Logout button** at the bottom of the sidebar (normal mode only).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Pending Devices checkbox click no longer opens the approval modal.
|
|
||||||
- NaN sent to the API when the status-check interval input is cleared.
|
|
||||||
- Potential open-redirect in the update badge (release URL scheme now validated).
|
|
||||||
|
|
||||||
## [1.10.1] - 2026-04-20
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- PNG export quality selector: Standard (1×), High (2×, default), Ultra (4×) (#89).
|
|
||||||
- Zone colour opacity sliders for text/border/background (#72).
|
|
||||||
- Optional service paths (e.g. `/admin`) appended to the clickable URL; port optional when a path is set (thanks @findthelorax) (#86).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Bulk-approved nodes appear on canvas immediately (node IDs were null before DB flush).
|
|
||||||
- Proxmox container mode: visible properties now shown; custom icon now applied.
|
|
||||||
- `approve_device` returns 404 on missing device, 409 on double-approve (was 200).
|
|
||||||
- Node form no longer retains previous-session values when reopened in Add mode (#87).
|
|
||||||
|
|
||||||
## [1.10.0] - 2026-04-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Bulk approve/hide pending devices (#70).
|
|
||||||
- IPv6 support & multiple comma-separated IPs per node (#60).
|
|
||||||
- Clickable IP addresses in the detail panel (#78).
|
|
||||||
- Connection handles on zone/group rect nodes (#58).
|
|
||||||
- Automatic DB backup before schema migrations.
|
|
||||||
- Drag group from its title (#76); double-click a node to open its edit modal (#65).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Node width no longer expands when long content overflows after a user resize.
|
|
||||||
- Proxmox nodes with `container_mode=false` restore their saved width on reload.
|
|
||||||
- Added curl to the backend image for the default healthcheck.
|
|
||||||
|
|
||||||
## [1.9.0] - 2026-04-09
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Node properties system**: dynamic key/value/icon/visible properties replacing static hardware fields; visible ones shown on the node card; 20 Lucide icons; existing hardware data auto-migrated.
|
|
||||||
- **Edge waypoints**: click `+` to add, drag to move, double-click to remove; Bezier and Smooth step supported; persisted to backend.
|
|
||||||
- **Basic edge animation** mode (native moving-dash), alongside Snake and Flow.
|
|
||||||
- App version shown in sidebar with a GitHub release update check.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Node height no longer overflows when properties are added to a resized node.
|
|
||||||
- Dot grid alignment with the snap grid.
|
|
||||||
- Node selection layout shift.
|
|
||||||
|
|
||||||
## [1.8.3] - 2026-04-06
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Finer canvas grid: snap reduced from 16px to 8px.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Updated frontend npm dependencies; upgraded lucide-react; removed unused Proxmox/LXC install scripts.
|
|
||||||
|
|
||||||
## [1.8.2] - 2026-04-05
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Scan no longer starts before confirmation — "Scan Network" opens the config modal first.
|
|
||||||
- Windows ping compatibility (`-n 1 -w 1000` instead of Linux/macOS flags).
|
|
||||||
|
|
||||||
## [1.8.1] - 2026-04-05
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Search now includes pending devices in both the canvas search bar (Ctrl+F) and command palette (Ctrl+K).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Timestamps (scan history, discovery time, Last Seen) now show local time instead of UTC.
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-04-04
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Configurable 1–4 bottom connection points per node.
|
|
||||||
- Fit view on load.
|
|
||||||
- Inline Type and Icon pickers in the node modal.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- **Scanner rewrite**: Phase 1 concurrent asyncio ping sweep (50 parallel pings) supplemented by `/proc/net/arp`; Phase 2 explicit `-sS`/`-sT` scan type with 60s host-timeout; resilient gather so one failing host no longer aborts the batch.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Root logger StreamHandler so `app.*` logs are visible in Docker.
|
|
||||||
- CIDR validation on frontend and backend to prevent nmap argument injection.
|
|
||||||
- Pre-fetch canvas/hidden IPs before the scan loop (no N+1 queries); ARP table read off the event loop.
|
|
||||||
- Hide/ignore on a missing device returns 404 instead of 500.
|
|
||||||
|
|
||||||
## [1.7.1] - 2026-04-02
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Concurrent status checks via `asyncio.gather`, ending "maximum instances reached" spam.
|
|
||||||
- Improved IoT detection: two-phase nmap scan finds Shelly, Sonoff, Tapo devices with no open TCP ports.
|
|
||||||
- mDNS/Bonjour discovery (`_shelly._tcp`, `_esphomelib._tcp`, `_hap._tcp`, `_mqtt._tcp`, …) in parallel with nmap.
|
|
||||||
- 20+ IoT vendor MAC OUIs and CoAP ports added; IoT ranked above generic server.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- LXC/VM container mode: attaching to a Proxmox no longer creates a spurious edge; nesting is instant.
|
|
||||||
- Scheduler reliability: DetachedInstanceError, duplicate timestamps, shutdown handling, interval validation.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Added `zeroconf==0.131.0` for mDNS discovery.
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-04-01
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Lasso / box selection** — draw a rectangle to select multiple nodes; hold Space to pan; lasso/pan toggle in the controls.
|
|
||||||
- **Named groups** — group 2+ nodes into a resizable, renamable container with hide-border option and per-member navigation; persisted across save/reload.
|
|
||||||
- **Canvas search (Ctrl+F)** — filter nodes by label, IP, hostname, service; live match count; click to fly the camera.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- LXC install script: `apt-get update` runs verbosely with `--fix-missing` to handle stale Debian mirrors (fixes #36).
|
|
||||||
- Group parent/child relationships restore after save + reload.
|
|
||||||
- Removed React Flow's default grey background from container node types.
|
|
||||||
|
|
||||||
## [1.6.0] - 2026-03-30
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Scan deduplication — pending devices already on canvas or hidden are skipped on rescan; stale entries purged at scan start.
|
|
||||||
- Dedicated Settings panel (status-check interval moved out of the Scan Config modal).
|
|
||||||
- Live interval update without a server restart.
|
|
||||||
- Separate `/api/v1/settings` endpoint for the check interval.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- DEL key deletes selected nodes (previously Backspace only).
|
|
||||||
- Node deletion via shortcut/detail panel is undoable with Ctrl+Z.
|
|
||||||
- APScheduler guards against double-start and unguarded reschedule calls.
|
|
||||||
|
|
||||||
## [1.5.0] - 2026-03-29
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Inline service editing in the detail panel (pencil icon).
|
|
||||||
- Edge animation modes: None, Snake, Flow — fully persisted.
|
|
||||||
- Zone improvements: rename Rectangle → Zone, border-width selector, label position, text-size options.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Edge animation not saved on new connections.
|
|
||||||
- Backend 422 when saving canvas with string animation values.
|
|
||||||
- Snake and Flow animations rendering identically in production.
|
|
||||||
- Login returned 500 instead of 401 when a bcrypt hash had `$` stripped by the shell (#21).
|
|
||||||
- Hardcoded CORS in docker-compose; clearer login error messages.
|
|
||||||
|
|
||||||
## [1.4.0] - 2026-03-28
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Live View** — share a read-only view of the canvas on your network, no login required.
|
|
||||||
- Resizable nodes with persisted width/height.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- QEMU arm64 crash during Docker image build (`npm ci` illegal instruction on ARM64).
|
|
||||||
- nginx `reload-or-start` fallback broken on fresh LXC installs.
|
|
||||||
|
|
||||||
### CI
|
|
||||||
|
|
||||||
- ShellCheck + hadolint linting; Docker smoke tests and full-stack integration tests.
|
|
||||||
|
|
||||||
## [1.3.3] - 2026-03-27
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Scan failures on fresh Docker installs — `service_signatures.json` moved into the app package so the volume mount no longer overwrites it.
|
|
||||||
- Missing `ping` on Docker (`iputils-ping` added).
|
|
||||||
- Thread-safe signature-file loading; clear error if the file is missing.
|
|
||||||
- CORS restricted to the HTTP methods/headers the frontend uses.
|
|
||||||
|
|
||||||
## [1.3.2] - 2026-03-27
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Ping check method on fresh Docker installs — `iputils-ping` added to the backend image (`python:3.13-slim` ships without `ping`).
|
|
||||||
|
|
||||||
## [1.3.1] - 2026-03-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **YAML import/export** of the full canvas topology (nodes, edges, hardware specs, connections); import merges without overwriting; Dagre auto-layout on import; toolbar Import/Export buttons.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- ESLint 10 incompatibility from `npm audit fix` — pinned back to ESLint 9.x.
|
|
||||||
|
|
||||||
### Docs
|
|
||||||
|
|
||||||
- Split installation into `INSTALLATION.md`; documented the network scanner and dev mode.
|
|
||||||
|
|
||||||
## [1.3.0] - 2026-03-21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Hardware specs per node (CPU model, cores, RAM, Disk) with "Show on node" and GB → TB formatting.
|
|
||||||
- `Docker Host` node type (Anchor icon), themed across all 5 themes.
|
|
||||||
- Group rectangle border style: Solid, Dashed, Dotted, Double, None.
|
|
||||||
- Categorized node-type selector (Hardware / Virtualization / IoT / Generic).
|
|
||||||
|
|
||||||
## [1.2.2] - 2026-03-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `scripts/update.sh` for fast in-place LXC updates (never touches `.env` or DB).
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- `crypto.randomUUID` crash on HTTP/LXC installs — polyfill fallback.
|
|
||||||
- WebSocket failure on LXC (hardcoded port 8000 bypassing Nginx); added `/api/v1/status/ws/` upgrade block.
|
|
||||||
- Production build crash on LXC (test files in `tsconfig.app.json`).
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- JWT no longer exposed in the WebSocket URL — sent as the first message after connect.
|
|
||||||
|
|
||||||
## [1.2.1] - 2026-03-16
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **MCP server** with HTTP/SSE transport for AI integration (Claude Code compatible); `parent_id` exposed in `update_node`; service-key auth for MCP → backend.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- "Add node" appeared broken with an empty Label — native validation doesn't render in Radix Dialog portals; now shows an inline error.
|
|
||||||
- SSE streaming crash; reduced `get_canvas` token usage.
|
|
||||||
|
|
||||||
## [1.2.0] - 2026-03-13
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Edge flow animation (per-edge toggle); Proxmox cluster edges animate bidirectionally.
|
|
||||||
- Keyboard shortcuts: Undo/Redo, Ctrl+K search, copy/paste, Ctrl+S save, `?` reference.
|
|
||||||
- Canvas history (50-entry undo/redo stack) with toolbar buttons.
|
|
||||||
- Node search spotlight (Ctrl+K) with fuzzy match and fly-to.
|
|
||||||
- Copy/paste nodes (Ctrl+C / Ctrl+V) with 50px offset and fresh IDs.
|
|
||||||
- Markdown table export of the node inventory.
|
|
||||||
- Clickable hostname in the detail panel.
|
|
||||||
- Shortcuts reference modal.
|
|
||||||
|
|
||||||
## [1.1.1] - 2026-03-11
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- New logo and favicon; page title updated to Homelable.
|
|
||||||
- **Hide IPs** toggle in the sidebar — masks the last two octets.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Auto-layout: peer nodes placed on the same row (no staircase); correct left-to-right ordering; child nodes always below their parent.
|
|
||||||
|
|
||||||
## [1.1.0] - 2026-03-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Group rectangles** — decorative resizable zones with configurable label/font/position/colours and z-order; saved with the canvas.
|
|
||||||
- Add/remove services manually in the detail panel.
|
|
||||||
- All TCP services now clickable (HTTPS auto-detected for 443/8443; non-web ports excluded).
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-03-09
|
|
||||||
|
|
||||||
First stable public release of **Homelable**, a self-hosted homelab visualization tool.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- **Canvas**: interactive React Flow diagram; 11 node types; 5 edge types; Proxmox nested nodes; Dagre auto-layout; zoom/pan; snap-to-grid; PNG export.
|
|
||||||
- **Network discovery**: nmap scanner over CIDR ranges; pending-device queue (approve/hide/ignore); service fingerprinting; MAC OUI detection for QEMU/Proxmox/VMware.
|
|
||||||
- **Status monitoring**: per-node ping/http/https/tcp/ssh/prometheus/health checks; live WebSocket updates; scheduled background checks.
|
|
||||||
- **Auth & persistence**: single-user JWT auth (bcrypt in `.env`); SQLite canvas state with an explicit Save button.
|
|
||||||
- **Standalone mode**: backend-free diagram editor with localStorage persistence.
|
|
||||||
- **Install options**: Docker Compose, Proxmox LXC, manual Debian/Ubuntu script.
|
|
||||||
|
|
||||||
[2.6.1]: https://github.com/Pouzor/homelable/compare/v2.6.0...v2.6.1
|
|
||||||
[2.6.0]: https://github.com/Pouzor/homelable/compare/v2.5.1...v2.6.0
|
|
||||||
[2.5.1]: https://github.com/Pouzor/homelable/compare/v2.5.0...v2.5.1
|
|
||||||
[2.5.0]: https://github.com/Pouzor/homelable/compare/v2.4.0...v2.5.0
|
|
||||||
[2.4.0]: https://github.com/Pouzor/homelable/compare/v2.3.0...v2.4.0
|
|
||||||
[2.3.0]: https://github.com/Pouzor/homelable/compare/v2.2.0...v2.3.0
|
|
||||||
[2.2.0]: https://github.com/Pouzor/homelable/compare/v2.1.1...v2.2.0
|
|
||||||
[2.1.1]: https://github.com/Pouzor/homelable/compare/v2.1.0...v2.1.1
|
|
||||||
[2.1.0]: https://github.com/Pouzor/homelable/compare/v2.0.3...v2.1.0
|
|
||||||
[2.0.3]: https://github.com/Pouzor/homelable/compare/v2.0.2...v2.0.3
|
|
||||||
[2.0.2]: https://github.com/Pouzor/homelable/compare/v2.0.1...v2.0.2
|
|
||||||
[2.0.1]: https://github.com/Pouzor/homelable/compare/v2.0.0...v2.0.1
|
|
||||||
[2.0.0]: https://github.com/Pouzor/homelable/compare/v1.13.0...v2.0.0
|
|
||||||
[1.13.0]: https://github.com/Pouzor/homelable/compare/v1.12.0...v1.13.0
|
|
||||||
[1.12.0]: https://github.com/Pouzor/homelable/compare/v1.11.0...v1.12.0
|
|
||||||
[1.11.0]: https://github.com/Pouzor/homelable/compare/v1.10.2...v1.11.0
|
|
||||||
[1.10.2]: https://github.com/Pouzor/homelable/compare/v1.10.1...v1.10.2
|
|
||||||
[1.10.1]: https://github.com/Pouzor/homelable/compare/v1.10.0...v1.10.1
|
|
||||||
[1.10.0]: https://github.com/Pouzor/homelable/compare/v1.9.0...v1.10.0
|
|
||||||
[1.9.0]: https://github.com/Pouzor/homelable/compare/v1.8.3...v1.9.0
|
|
||||||
[1.8.3]: https://github.com/Pouzor/homelable/compare/v1.8.2...v1.8.3
|
|
||||||
[1.8.2]: https://github.com/Pouzor/homelable/compare/v1.8.1...v1.8.2
|
|
||||||
[1.8.1]: https://github.com/Pouzor/homelable/compare/v1.8.0...v1.8.1
|
|
||||||
[1.8.0]: https://github.com/Pouzor/homelable/compare/v1.7.1...v1.8.0
|
|
||||||
[1.7.1]: https://github.com/Pouzor/homelable/compare/v1.7.0...v1.7.1
|
|
||||||
[1.7.0]: https://github.com/Pouzor/homelable/compare/v1.6.0...v1.7.0
|
|
||||||
[1.6.0]: https://github.com/Pouzor/homelable/compare/v1.5.0...v1.6.0
|
|
||||||
[1.5.0]: https://github.com/Pouzor/homelable/compare/v1.4.0...v1.5.0
|
|
||||||
[1.4.0]: https://github.com/Pouzor/homelable/compare/v1.3.3...v1.4.0
|
|
||||||
[1.3.3]: https://github.com/Pouzor/homelable/compare/v1.3.2...v1.3.3
|
|
||||||
[1.3.2]: https://github.com/Pouzor/homelable/compare/v1.3.1...v1.3.2
|
|
||||||
[1.3.1]: https://github.com/Pouzor/homelable/compare/v1.3.0...v1.3.1
|
|
||||||
[1.3.0]: https://github.com/Pouzor/homelable/compare/v1.2.2...v1.3.0
|
|
||||||
[1.2.2]: https://github.com/Pouzor/homelable/compare/v1.2.1...v1.2.2
|
|
||||||
[1.2.1]: https://github.com/Pouzor/homelable/compare/v1.2.0...v1.2.1
|
|
||||||
[1.2.0]: https://github.com/Pouzor/homelable/compare/v1.1.1...v1.2.0
|
|
||||||
[1.1.1]: https://github.com/Pouzor/homelable/compare/v1.1.0...v1.1.1
|
|
||||||
[1.1.0]: https://github.com/Pouzor/homelable/compare/v1.0.0...v1.1.0
|
|
||||||
[1.0.0]: https://github.com/Pouzor/homelable/releases/tag/v1.0.0
|
|
||||||
-276
@@ -1,276 +0,0 @@
|
|||||||
# Contributing to Homelable
|
|
||||||
|
|
||||||
Thanks for taking the time to contribute! This document covers everything you need to get started.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Ways to Contribute](#ways-to-contribute)
|
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
|
||||||
- [Suggesting Features](#suggesting-features)
|
|
||||||
- [Development Setup](#development-setup)
|
|
||||||
- [Project Structure](#project-structure)
|
|
||||||
- [Coding Standards](#coding-standards)
|
|
||||||
- [Testing](#testing)
|
|
||||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
|
||||||
- [Commit Message Format](#commit-message-format)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ways to Contribute
|
|
||||||
|
|
||||||
- Report bugs or unexpected behavior
|
|
||||||
- Suggest new features or improvements
|
|
||||||
- Fix open issues (check the [issue tracker](https://github.com/Pouzor/homelable/issues))
|
|
||||||
- Improve documentation
|
|
||||||
- Add service signatures to `service_signatures.json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reporting Bugs
|
|
||||||
|
|
||||||
Before opening an issue, search existing ones to avoid duplicates.
|
|
||||||
|
|
||||||
When filing a bug, include:
|
|
||||||
|
|
||||||
- **Homelable version** (visible in the sidebar bottom-left)
|
|
||||||
- **Deployment method** (Docker Compose, Proxmox LXC, source)
|
|
||||||
- **Steps to reproduce**
|
|
||||||
- **Expected vs actual behavior**
|
|
||||||
- **Relevant logs** (`docker compose logs backend` / `docker compose logs frontend`)
|
|
||||||
- **Browser console errors** if it's a UI issue
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggesting Features
|
|
||||||
|
|
||||||
Open an issue with the `enhancement` label. Describe:
|
|
||||||
|
|
||||||
- The problem you're trying to solve
|
|
||||||
- Your proposed solution
|
|
||||||
- Any alternatives you considered
|
|
||||||
|
|
||||||
For large changes, discuss first before writing code — it avoids wasted effort.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Development Setup
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Node.js 20+** and **npm**
|
|
||||||
- **Python 3.11–3.13** (3.14 not yet supported by all dependencies)
|
|
||||||
- **nmap** installed on your system (required for scanner)
|
|
||||||
- **Docker + Docker Compose** (optional, for full-stack testing)
|
|
||||||
|
|
||||||
### 1. Clone the repo
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/Pouzor/homelable.git
|
|
||||||
cd homelable
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
python3.13 -m venv .venv
|
|
||||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
||||||
pip install -r requirements.txt
|
|
||||||
|
|
||||||
# Copy and configure environment
|
|
||||||
cp .env.example .env # edit AUTH_PASSWORD_HASH, SECRET_KEY, etc.
|
|
||||||
|
|
||||||
# Start the backend (auto-reloads on change)
|
|
||||||
uvicorn app.main:app --reload --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
API docs available at `http://localhost:8000/docs`.
|
|
||||||
|
|
||||||
### 3. Frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run dev # http://localhost:5173
|
|
||||||
```
|
|
||||||
|
|
||||||
Vite proxies `/api` to `localhost:8000` — the backend must be running.
|
|
||||||
|
|
||||||
### 4. Verify tooling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/verify-tooling.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
homelable/
|
|
||||||
├── frontend/src/
|
|
||||||
│ ├── components/
|
|
||||||
│ │ ├── canvas/ # React Flow canvas, custom nodes & edges
|
|
||||||
│ │ ├── panels/ # Sidebar, detail panel, toolbar
|
|
||||||
│ │ ├── modals/ # Add/edit node, scan config, pending devices
|
|
||||||
│ │ └── ui/ # Shadcn/ui base components
|
|
||||||
│ ├── stores/ # Zustand state (canvas, auth, scan)
|
|
||||||
│ ├── hooks/ # Custom React hooks
|
|
||||||
│ ├── types/ # TypeScript interfaces & enums
|
|
||||||
│ ├── api/ # Axios client & typed endpoints
|
|
||||||
│ └── utils/ # Layout, export, color helpers
|
|
||||||
│
|
|
||||||
├── backend/app/
|
|
||||||
│ ├── api/routes/ # FastAPI route handlers
|
|
||||||
│ ├── services/ # Scanner, status checker, canvas service
|
|
||||||
│ ├── db/ # SQLAlchemy models, Alembic migrations
|
|
||||||
│ ├── schemas/ # Pydantic request/response schemas
|
|
||||||
│ └── core/ # Config, JWT, scheduler
|
|
||||||
│
|
|
||||||
├── docker/ # Nginx configs
|
|
||||||
├── scripts/ # LXC bootstrap, dev helpers
|
|
||||||
└── mcp/ # MCP server (AI integration)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coding Standards
|
|
||||||
|
|
||||||
### General
|
|
||||||
|
|
||||||
- No untested code merged — every feature or fix must include tests
|
|
||||||
- Keep changes focused — one concern per PR
|
|
||||||
|
|
||||||
### Frontend (TypeScript + React)
|
|
||||||
|
|
||||||
- Strict TypeScript — no `any`, no type assertions unless truly necessary
|
|
||||||
- React Flow node domain fields go in `node.data`, never on the node root
|
|
||||||
- State management via Zustand stores — no prop drilling beyond 2 levels
|
|
||||||
- Styling via TailwindCSS utility classes — follow the existing [design system](#design-system)
|
|
||||||
- Run before committing:
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run lint
|
|
||||||
npm run typecheck
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend (Python + FastAPI)
|
|
||||||
|
|
||||||
- Python 3.11+ syntax
|
|
||||||
- Pydantic v2 schemas for all request/response types
|
|
||||||
- SQLAlchemy async sessions — never block the event loop
|
|
||||||
- Scanner logic runs in a background thread — never in an async route directly
|
|
||||||
- All schema changes via Alembic migrations — never modify tables directly
|
|
||||||
- Run before committing:
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
source .venv/bin/activate
|
|
||||||
ruff check .
|
|
||||||
pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design System
|
|
||||||
|
|
||||||
| Token | Value |
|
|
||||||
|---|---|
|
|
||||||
| Background | `#0d1117` |
|
|
||||||
| Surface | `#161b22` |
|
|
||||||
| Card | `#21262d` |
|
|
||||||
| Accent cyan | `#00d4ff` |
|
|
||||||
| Online | `#39d353` |
|
|
||||||
| Offline | `#f85149` |
|
|
||||||
| Pending | `#e3b341` |
|
|
||||||
| Font (UI) | Inter |
|
|
||||||
| Font (IPs/ports) | JetBrains Mono |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Tests run automatically via a pre-commit hook when frontend or backend files are staged.
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm test # run all tests
|
|
||||||
npm run test:coverage # with coverage report
|
|
||||||
```
|
|
||||||
|
|
||||||
Test files live in `__tests__/` next to their module, named `*.test.ts(x)`.
|
|
||||||
|
|
||||||
**What to test:** Zustand store actions, utility functions, non-trivial component logic.
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
source .venv/bin/activate
|
|
||||||
pytest # run all tests
|
|
||||||
pytest -v tests/test_nodes.py # single file
|
|
||||||
```
|
|
||||||
|
|
||||||
Test files live in `backend/tests/test_*.py`.
|
|
||||||
|
|
||||||
**What to test:** all API routes (happy path + error cases), auth flows, service logic.
|
|
||||||
|
|
||||||
Use the `client` and `headers` fixtures from `conftest.py` — they provide an in-memory SQLite database so tests are isolated and fast.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Submitting a Pull Request
|
|
||||||
|
|
||||||
1. **Fork** the repo and create a branch from `main`:
|
|
||||||
```bash
|
|
||||||
git checkout -b feat/my-feature
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Make your changes** — include tests.
|
|
||||||
|
|
||||||
3. **Run the full test suite** (frontend + backend) and make sure everything passes.
|
|
||||||
|
|
||||||
4. **Open a PR** against `main`:
|
|
||||||
- Use a clear title (see commit format below)
|
|
||||||
- Describe what changed and why
|
|
||||||
- Reference any related issues (`Closes #123`)
|
|
||||||
- Include screenshots for UI changes
|
|
||||||
|
|
||||||
5. Keep the PR focused — one feature or fix per PR. Large refactors should be discussed in an issue first.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commit Message Format
|
|
||||||
|
|
||||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>: <short description>
|
|
||||||
|
|
||||||
[optional body]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Type | When to use |
|
|
||||||
|---|---|
|
|
||||||
| `feat` | New feature |
|
|
||||||
| `fix` | Bug fix |
|
|
||||||
| `docs` | Documentation only |
|
|
||||||
| `refactor` | Code change with no behavior change |
|
|
||||||
| `test` | Adding or fixing tests |
|
|
||||||
| `chore` | Build, deps, tooling |
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
```
|
|
||||||
feat: add logout button to sidebar
|
|
||||||
fix: stop click propagation on pending device checkbox
|
|
||||||
docs: add CONTRIBUTING.md
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Questions?
|
|
||||||
|
|
||||||
Open a [GitHub Discussion](https://github.com/Pouzor/homelable/discussions) or drop a comment on a relevant issue.
|
|
||||||
+2
-3
@@ -2,14 +2,13 @@ FROM python:3.13-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install nmap for network scanning + iputils-ping for ping-based status checks + curl for the health check
|
# Install nmap for network scanning + iputils-ping for ping-based status checks
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping curl && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends nmap iputils-ping && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY backend/requirements.txt .
|
COPY backend/requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY backend/ .
|
COPY backend/ .
|
||||||
COPY VERSION /app/VERSION
|
|
||||||
|
|
||||||
# Create data directory (volume mount point)
|
# Create data directory (volume mount point)
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data
|
||||||
|
|||||||
+1
-3
@@ -1,8 +1,7 @@
|
|||||||
# Stage 1: build
|
# Stage 1: build
|
||||||
# Use the native build platform so npm ci never runs under QEMU emulation.
|
# Use the native build platform so npm ci never runs under QEMU emulation.
|
||||||
# The build output (static HTML/JS/CSS) is platform-independent.
|
# The build output (static HTML/JS/CSS) is platform-independent.
|
||||||
# node:20-slim (Debian/glibc) avoids lightningcss musl binary resolution issues on Alpine.
|
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
||||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
|
||||||
|
|
||||||
ARG VITE_STANDALONE=false
|
ARG VITE_STANDALONE=false
|
||||||
ENV VITE_STANDALONE=$VITE_STANDALONE
|
ENV VITE_STANDALONE=$VITE_STANDALONE
|
||||||
@@ -12,7 +11,6 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
COPY VERSION ../VERSION
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: serve
|
# Stage 2: serve
|
||||||
|
|||||||
-255
@@ -1,255 +0,0 @@
|
|||||||
# Homelable Features
|
|
||||||
|
|
||||||
Here's what Homelable can do. One line on what each feature is, then how to switch it on and use it.
|
|
||||||
|
|
||||||
> **Two modes.** Same UI, two ways to run it:
|
|
||||||
> - **Full mode**, with the backend (Docker/LXC). Everything works.
|
|
||||||
> - **Standalone mode** (`VITE_STANDALONE=true`), no backend, canvas lives in your browser's `localStorage`. Great for just drawing; anything that needs a server (scanning, imports, device inventory, floor-plan upload, live view) is hidden.
|
|
||||||
>
|
|
||||||
> Features marked 🔒 need Full mode.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
1. [Zones](#1-zones)
|
|
||||||
2. [Groups & Nesting](#2-groups--nesting)
|
|
||||||
3. [Text Annotations](#3-text-annotations)
|
|
||||||
4. [Multiple Canvases](#4-multiple-canvases)
|
|
||||||
5. [Customize Style](#5-customize-style)
|
|
||||||
6. [Floor Plan](#6-floor-plan-)
|
|
||||||
7. [Network Scanner (IP import)](#7-network-scanner-ip-import-)
|
|
||||||
8. [Zigbee Import](#8-zigbee-import-)
|
|
||||||
9. [Z-Wave Import](#9-z-wave-import-)
|
|
||||||
10. [Proxmox VE Import](#10-proxmox-ve-import-)
|
|
||||||
11. [Device Inventory](#11-device-inventory-)
|
|
||||||
12. [Live Status Monitoring](#12-live-status-monitoring-)
|
|
||||||
13. [Export (PNG / SVG / YAML / Markdown)](#13-export)
|
|
||||||
14. [Live View (read-only public canvas)](#14-live-view-)
|
|
||||||
15. [Gethomepage Widget](#15-gethomepage-widget-)
|
|
||||||
16. [MCP Server (AI integration)](#16-mcp-server-)
|
|
||||||
17. [Settings & Shortcuts](#17-settings--shortcuts)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Zones
|
|
||||||
|
|
||||||
**What:** Labeled boxes to group devices by area: "Living room", "Rack 1", "DMZ", whatever makes sense to you.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Sidebar → **Add Zone**. Give it a title and a color, then drag it around and resize it.
|
|
||||||
- Drop nodes onto a zone and Homelable asks if you want to add them to it.
|
|
||||||
- Zones sit behind your nodes and move on their own. They're just there to keep things tidy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Groups & Nesting
|
|
||||||
|
|
||||||
**What:** Some devices hold others, like a **Proxmox** host with its **VMs** and **LXCs** inside. Those show up as an expandable container.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Drag a `vm` or `lxc` onto a `proxmox` node, confirm **Add to container**, and it becomes a child.
|
|
||||||
- Click the container header to fold it open or shut; it resizes itself around what's inside.
|
|
||||||
- The Zigbee and Z-Wave imports build the same kind of hierarchy for you (coordinator → routers → end devices).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Text Annotations
|
|
||||||
|
|
||||||
**What:** Loose text labels for notes, section titles, or anything you want to call out on the canvas.
|
|
||||||
|
|
||||||
**Use:** Sidebar → **Add Text**, type, drop it where you want, style it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Multiple Canvases
|
|
||||||
|
|
||||||
**What:** More than one diagram in a single install, say "Network", "Home automation", "Rack layout", each with its own nodes, links, floor plan, and style.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- The **canvas switcher** is at the top of the sidebar. Click to jump between canvases, or **New Canvas** to start a fresh one.
|
|
||||||
- Hover a canvas to **Edit** it (name, icon, floor plan) or **Delete** it. You can't delete the last one.
|
|
||||||
- Each canvas saves on its own, so hit **Save Canvas** after you change something.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Customize Style
|
|
||||||
|
|
||||||
**What:** Repaint the whole thing with a preset theme, or roll your own node and edge colors.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Toolbar → **Style**. Pick a preset: **Default**, **Dark**, **Light**, **Neon**, or **Matrix**.
|
|
||||||
- Or pick **Custom** and hit its **Edit** button to set border/background colors per node type and link colors per edge type.
|
|
||||||
- Theme and custom colors are saved **per canvas** on your next **Save Canvas**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Floor Plan 🔒
|
|
||||||
|
|
||||||
**What:** Put a background image (a house plan, an office layout, a rack diagram) behind a canvas and lay your devices out on top of it.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Open the **canvas switcher** → **Edit** the active canvas (or double-click the floor plan already on the canvas).
|
|
||||||
- In the **Floor Plan** section, upload an image and set its size and lock state.
|
|
||||||
- The image lives on the backend and is loaded by URL, never baked into the canvas, so your canvases stay light. *(See ADR-001; floor plans are Full mode only.)*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Network Scanner (IP import) 🔒
|
|
||||||
|
|
||||||
**What:** Point `nmap` at your network, fingerprint the services it finds, and turn hosts into nodes.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Sidebar → **Scan Network**. Scan History opens and keeps refreshing until it's done.
|
|
||||||
2. Set the CIDR ranges you want in `SCANNER_RANGES` (`.env`), or override them per scan in the dialog.
|
|
||||||
3. Whatever it finds shows up in the **Device Inventory** (below) to approve, hide, or ignore.
|
|
||||||
|
|
||||||
**Deep scan (custom ports):** to catch services on odd ports, add them in `.env`:
|
|
||||||
```env
|
|
||||||
SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
|
||||||
SCANNER_HTTP_PROBE_ENABLED=true
|
|
||||||
SCANNER_HTTP_VERIFY_TLS=false
|
|
||||||
```
|
|
||||||
|
|
||||||
**Root note:** SYN scans and OS detection need root. If a scan trips on permissions, run `scripts/run_scan.py` with `sudo`, or on Linux give nmap the `NET_RAW` capability. Full details in the [README](./README.md#network-scanner).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Zigbee Import 🔒
|
|
||||||
|
|
||||||
**What:** Pull your **Zigbee2MQTT** topology in over MQTT and drop every device on the canvas as a typed node.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Sidebar → **Zigbee Import**.
|
|
||||||
2. Enter broker host/port (default `1883`), any credentials, and the base topic (default `zigbee2mqtt`).
|
|
||||||
3. **Test Connection** → **Fetch Devices** → pick from the grouped list → **Add N to Canvas**.
|
|
||||||
|
|
||||||
Nodes come in as `zigbee_coordinator` / `zigbee_router` / `zigbee_enddevice`. The hierarchy (coordinator → routers → end devices) and **LQI** are filled in automatically. More: [docs/zigbee-import.md](./docs/zigbee-import.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Z-Wave Import 🔒
|
|
||||||
|
|
||||||
**What:** Same idea for **Z-Wave JS UI**, over the same MQTT broker.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Sidebar → **Z-Wave Import**.
|
|
||||||
2. Enter broker host/port, any credentials, the MQTT prefix (default `zwave`), and the gateway name (default `zwavejs2mqtt`).
|
|
||||||
3. **Test Connection** → send them to **Pending** or straight to the **Canvas** → import → pick devices → **Add N to Canvas**.
|
|
||||||
|
|
||||||
Nodes: `zwave_coordinator` / `zwave_router` / `zwave_enddevice`. The hierarchy comes from each node's neighbor list (Z-Wave has no LQI). More: [docs/zwave-import.md](./docs/zwave-import.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Proxmox VE Import 🔒
|
|
||||||
|
|
||||||
**What:** Pull your **Proxmox VE** inventory (hosts, VMs, LXC) in over the Proxmox REST API — typed, named nodes with run state and hardware specs. Optional scheduled **auto-sync**; guest IPs already found by a scan are merged, not duplicated.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Create a read-only API token in Proxmox (Datacenter → Permissions → API Tokens, role `PVEAuditor`).
|
|
||||||
2. Sidebar → **Proxmox Import**.
|
|
||||||
3. Enter host, port (default `8006`), and the token (`user@realm!tokenid` + secret) — or leave blank to use the server token.
|
|
||||||
4. **Test Connection** → send to **Pending** or the **Canvas** → import → pick devices → **Add N to Canvas**.
|
|
||||||
|
|
||||||
Nodes: `proxmox` (host) / `vm` / `lxc`, linked host→guest by a `virtual` edge. The token is env-only, never stored on disk, never returned by the API. Enable auto-sync from **Settings** once `PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET` are set. More: [docs/proxmox-import.md](./docs/proxmox-import.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11. Device Inventory 🔒
|
|
||||||
|
|
||||||
**What:** The holding pen for everything found by a scan or import that isn't on the canvas yet, plus a separate **Hidden Devices** list.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Sidebar → **Device Inventory**. Each entry shows IP, MAC, hostname, and any OS and services detected.
|
|
||||||
- Per device: **Approve** to drop a typed node on the canvas, **Hide** to stash it (you can get it back), or **Ignore** to dismiss it.
|
|
||||||
- **Hidden Devices** is the sidebar entry where you review and restore anything you've hidden.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 12. Live Status Monitoring 🔒
|
|
||||||
|
|
||||||
**What:** Keeps checking each node and shows its status (🟢 online / 🔴 offline / ⚫ unknown) right on the canvas.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Pick a **check method** per node when you add or edit it:
|
|
||||||
|
|
||||||
| Method | Checks |
|
|
||||||
|--------|--------|
|
|
||||||
| `ping` | ICMP reachability |
|
|
||||||
| `http` | GET, OK if status < 500 |
|
|
||||||
| `https` | GET with TLS verify |
|
|
||||||
| `tcp` | TCP connect to `host:port` |
|
|
||||||
| `ssh` | TCP connect to port 22 |
|
|
||||||
| `prometheus` | GET `/metrics` |
|
|
||||||
| `health` | GET `/health` |
|
|
||||||
|
|
||||||
- Checks run on a timer (`STATUS_CHECKER_INTERVAL`, 60s by default) and stream to the UI over WebSocket, no refresh. The sidebar footer keeps a running online/offline tally.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 13. Export
|
|
||||||
|
|
||||||
**What:** Get your canvas out as a picture or as structured data.
|
|
||||||
|
|
||||||
**Use (toolbar):**
|
|
||||||
- **PNG**, a snapshot of the canvas, quality of your choice. Works in standalone too.
|
|
||||||
- **SVG**, vector export, keeps fonts, icons, and colors crisp. Same dialog as PNG.
|
|
||||||
- **Export (YAML)**, the whole canvas (nodes + links) as YAML you can re-import.
|
|
||||||
- **Markdown**, copies your device inventory as a Markdown table, handy for docs or a README.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 14. Live View 🔒
|
|
||||||
|
|
||||||
**What:** A read-only, no-login snapshot of a canvas you can share on your LAN. Off by default.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Add `LIVEVIEW_KEY=your-secret-key` to `.env`, then `docker compose restart backend`.
|
|
||||||
2. Open `http://<your-homelab-ip>/view?key=your-secret-key`.
|
|
||||||
|
|
||||||
Pan and zoom only, no editing. Click a node with an IP and it opens in a new tab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Gethomepage Widget 🔒
|
|
||||||
|
|
||||||
**What:** A tiny JSON stats endpoint for [gethomepage](https://gethomepage.dev)'s `customapi` widget. Off by default.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Add `HOMEPAGE_API_KEY=your-secret-key` to `.env`, restart the backend.
|
|
||||||
2. `GET /api/v1/stats/summary` with header `X-API-Key: your-secret-key` returns node counts, online/offline, pending, zigbee, and last scan time.
|
|
||||||
|
|
||||||
Widget snippet lives in the [README](./README.md#gethomepage-widget-read-only-stats).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. MCP Server 🔒
|
|
||||||
|
|
||||||
**What:** A [Model Context Protocol](https://modelcontextprotocol.io) server so an MCP client (Claude Code, Claude Desktop, Open WebUI…) can read and change your topology. Optional, runs as its own service.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
1. Add the keys to `.env`:
|
|
||||||
```env
|
|
||||||
MCP_API_KEY=mcp_sk_changeme # AI client -> MCP server
|
|
||||||
MCP_SERVICE_KEY=svc_changeme # MCP server -> backend (internal only)
|
|
||||||
# generate: python3 -c "import secrets; print(secrets.token_hex(32))"
|
|
||||||
```
|
|
||||||
2. `docker compose up -d mcp` (listens on `:8001`). No Docker? `sudo bash scripts/lxc-mcp-install.sh`.
|
|
||||||
3. Point your client at `http://<your-homelab-ip>:8001/mcp` with header `X-API-Key: <your key>`.
|
|
||||||
|
|
||||||
The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and edges, kick off scans, and approve or hide devices. Keep port 8001 firewalled to your LAN. Full setup in the [README](./README.md#mcp-server-ai-integration-optional).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 17. Settings & Shortcuts
|
|
||||||
|
|
||||||
**What:** App config and keyboard shortcuts.
|
|
||||||
|
|
||||||
**Use:**
|
|
||||||
- Sidebar → **Settings** for app-level config.
|
|
||||||
- **Search** to find nodes fast.
|
|
||||||
- Open the **Shortcuts** modal for the full key list (Save `Ctrl/Cmd+S`, undo/redo, and the rest).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
|
|
||||||
+32
-17
@@ -11,18 +11,9 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
|
|||||||
|
|
||||||
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
> Change the password before exposing to a network: edit `.env` and update `AUTH_USERNAME` / `AUTH_PASSWORD_HASH`.
|
||||||
>
|
>
|
||||||
Generate a new hash:
|
> Generate a new hash: `docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"`
|
||||||
```bash
|
>
|
||||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
|
> ⚠️ Keep the single quotes around the hash value in `.env` — bcrypt hashes contain `$` characters that Docker Compose would otherwise misinterpret.
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
⚠️ **bcrypt hashes contain `$` characters** — how to handle them depends on where you set the value:
|
|
||||||
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
|
|
||||||
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
|
|
||||||
```bash
|
|
||||||
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start — Frontend only
|
## Quick Start — Frontend only
|
||||||
|
|
||||||
@@ -53,13 +44,37 @@ docker compose up -d
|
|||||||
|
|
||||||
## Proxmox LXC Install
|
## Proxmox LXC Install
|
||||||
|
|
||||||
You can now install Homelable with community-scripts (proxmox-VE) :
|
Run this **on the Proxmox host** — it creates a Debian 12 LXC container and installs Homelable inside automatically:
|
||||||
|
|
||||||
`https://community-scripts.org/scripts/homelable`
|
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/homelable.sh)"
|
bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Default container settings: 2 cores, 1 GB RAM, 8 GB disk, DHCP on `vmbr0`. Override before running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CTID=150 RAM=2048 STORAGE=local-zfs bash <(curl -fsSL .../install-proxmox.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend runs as a systemd service, the frontend is served via nginx on port 80.
|
||||||
|
|
||||||
|
> To install manually inside an existing Debian/Ubuntu machine or LXC:
|
||||||
|
> ```bash
|
||||||
|
> bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/lxc-install.sh)
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Update (LXC)
|
||||||
|
|
||||||
|
Run the update script inside the container (pulls latest code, rebuilds frontend, restarts services — `.env` and database are never touched):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash /opt/homelable/scripts/update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Or directly from GitHub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo bash <(curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/scripts/update.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Remy Jardinet
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -1,44 +1,13 @@
|
|||||||
<h1 align="center"><img src="docs/logo/icon-inline.svg" alt="Homelable" width="58" align="middle" /> Homelable</h1>
|
# Homelable
|
||||||
|
|
||||||
<p align="center">
|
Homelable is a self-hosted infrastructure visualization solution. It provides a network scanning feature to accelerate the identification of machines and services deployed on your local infrastructure.
|
||||||
<strong>Self-hosted homelab infrastructure visualization, scanning & live monitoring</strong>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Homelable also offers a healthcheck system (WIP) through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
||||||
<a href="https://github.com/Pouzor/homelable/releases/latest"><img src="https://img.shields.io/github/v/release/Pouzor/homelable" alt="Latest release" /></a>
|
|
||||||
<a href="https://github.com/Pouzor/homelable/actions/workflows/docker-ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Pouzor/homelable/docker-ci.yml?branch=main&label=build" alt="Build status" /></a>
|
|
||||||
<a href="https://github.com/Pouzor/homelable/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
|
|
||||||
<a href="https://github.com/Pouzor/homelable/issues"><img src="https://img.shields.io/github/issues/Pouzor/homelable" alt="Issues" /></a>
|
|
||||||
<a href="https://github.com/Pouzor/homelable/stargazers"><img src="https://img.shields.io/github/stars/Pouzor/homelable?style=social" alt="Stars" /></a>
|
|
||||||
<a href="https://github.com/Pouzor/homelable/network/members"><img src="https://img.shields.io/github/forks/Pouzor/homelable?style=social" alt="Forks" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://trendshift.io/repositories/24461?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-24461" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/24461/daily?language=TypeScript" alt="Pouzor%2Fhomelable | Trendshift" width="130" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="#screenshots">Screenshots</a> ·
|
|
||||||
<a href="#features">Features</a> ·
|
|
||||||
<a href="#installation">Installation</a> ·
|
|
||||||
<a href="#network-scanner">Network Scanner</a> ·
|
|
||||||
<a href="#zigbee2mqtt-import">Zigbee / Z-Wave</a> ·
|
|
||||||
<a href="#proxmox-ve-import">Proxmox</a> ·
|
|
||||||
<a href="#live-view-read-only-public-canvas">Live View</a> ·
|
|
||||||
<a href="#mcp-server-ai-integration-optional">MCP Server</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About
|
|
||||||
|
|
||||||
Homelable is a self-hosted infrastructure visualization solution. It provides a network/zigbee scanning feature to accelerate the identification of machines, devices and services deployed on your local infrastructure.
|
|
||||||
|
|
||||||
Homelable also offers a healthcheck system through multiple methods (ping/TCP, /health API, etc.) to get a global overview of online/offline services.
|
|
||||||
|
|
||||||
You can also select some pre-built design styles, or personalize each device in your diagram.
|
You can also select some pre-built design styles, or personalize each device in your diagram.
|
||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -46,22 +15,13 @@ If you are running <img width="22" height="22" align="top" alt="New_Home_Assist
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
|
||||||
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
|
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
||||||
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
|
<img src="docs/homelable3.png" alt="Homelable sidebar and scan" width="40%" />
|
||||||
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
|
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="40%" />
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
|
|
||||||
|
|
||||||
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
|
||||||
@@ -77,20 +37,6 @@ The scanner runs `nmap -sV --open` on your configured CIDR ranges and populates
|
|||||||
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
To save you time when mapping your infrastructure, Homlable can scan your network and report all the services it detects. It can also identify them, saving you even more time.
|
||||||
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
Click **Scan Network** in the sidebar. The Scan History tab opens automatically and refreshes every 3 seconds until the scan completes.
|
||||||
|
|
||||||
### Deep scan (custom ports)
|
|
||||||
|
|
||||||
By default the scanner only probes nmap's standard port set. To fingerprint services on non-standard ports, enable the deep scan via `.env` (all options are overridable per-scan from the scan dialog):
|
|
||||||
|
|
||||||
```env
|
|
||||||
# JSON array of port specs — each entry is a single port "N" or an inclusive
|
|
||||||
# range "N-M" (1–65535, N <= M). These are ports, not CIDRs or bare integers.
|
|
||||||
SCANNER_HTTP_RANGES=["8080","9000-9100"]
|
|
||||||
SCANNER_HTTP_PROBE_ENABLED=true # send an HTTP probe to those ports for service ID
|
|
||||||
SCANNER_HTTP_VERIFY_TLS=false # verify TLS certs on the HTTP probe
|
|
||||||
```
|
|
||||||
|
|
||||||
The listed ports are appended to nmap's `-p` spec. Invalid entries (out-of-range, malformed, or reversed ranges) are silently skipped.
|
|
||||||
|
|
||||||
### macOS / root privileges
|
### 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:
|
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:
|
||||||
@@ -128,180 +74,7 @@ Homelable continuously monitors your nodes and displays their live status (onlin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Zigbee2MQTT Import
|
## MCP Server (AI Integration) (optionnal)
|
||||||
|
|
||||||
Homelable can connect directly to your MQTT broker and import your Zigbee network topology from **Zigbee2MQTT**, placing each device on the canvas as a typed node.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
|
||||||
- **Zigbee2MQTT** connected to the broker with at least one device paired
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
1. Click **Zigbee Import** in the left sidebar (below "Scan Network")
|
|
||||||
2. Enter your broker host, port (default `1883`), optional credentials, and base topic (default `zigbee2mqtt`)
|
|
||||||
3. Click **Test Connection** to verify reachability, then **Fetch Devices**
|
|
||||||
4. Select the devices you want from the grouped list (Coordinator / Router / End Device)
|
|
||||||
5. Click **Add N to Canvas** — devices are placed in a grid with IoT edges
|
|
||||||
|
|
||||||
### Node Types
|
|
||||||
|
|
||||||
| Type | Z2M Device | Icon |
|
|
||||||
|------|-----------|------|
|
|
||||||
| `zigbee_coordinator` | Coordinator | Network hub |
|
|
||||||
| `zigbee_router` | Router (mains-powered) | Radio |
|
|
||||||
| `zigbee_enddevice` | End Device (battery) | Antenna |
|
|
||||||
|
|
||||||
Hierarchy is set automatically: coordinator → routers → end devices (`parent_id`).
|
|
||||||
LQI (Link Quality Indicator) is stored as a node property.
|
|
||||||
|
|
||||||
> **Full documentation:** [docs/zigbee-import.md](./docs/zigbee-import.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Z-Wave Import
|
|
||||||
|
|
||||||
Homelable can also import your **Z-Wave** network from **Z-Wave JS UI** (formerly `zwavejs2mqtt`) over the same MQTT broker, dropping each node on the canvas as a typed node.
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- A running **MQTT broker** (e.g. Mosquitto) accessible from the Homelable host
|
|
||||||
- **Z-Wave JS UI** connected to the broker with its MQTT gateway enabled and at least one node included
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
1. Click **Z-Wave Import** in the left sidebar (below "Zigbee Import")
|
|
||||||
2. Enter your broker host, port (default `1883`), optional credentials, MQTT prefix (default `zwave`), and gateway name (default `zwavejs2mqtt`)
|
|
||||||
3. Click **Test Connection** to verify reachability
|
|
||||||
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Devices**
|
|
||||||
5. Select the devices you want from the grouped list (Controller / Router / End Device) and click **Add N to Canvas**
|
|
||||||
|
|
||||||
### Node Types
|
|
||||||
|
|
||||||
| Type | Z-Wave Role | Icon |
|
|
||||||
|------|-------------|------|
|
|
||||||
| `zwave_coordinator` | Controller | Network hub |
|
|
||||||
| `zwave_router` | Routing (mains-powered) node | Radio |
|
|
||||||
| `zwave_enddevice` | End Device (battery) | Antenna |
|
|
||||||
|
|
||||||
Hierarchy is set automatically: controller → routers → end devices (`parent_id`), derived from each node's neighbor list. Z-Wave has no LQI, so that property is omitted.
|
|
||||||
|
|
||||||
> **Full documentation:** [docs/zwave-import.md](./docs/zwave-import.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxmox VE Import
|
|
||||||
|
|
||||||
Homelable can import your **Proxmox VE** inventory over the Proxmox REST API — hosts, VMs and LXC containers arrive as typed, named nodes with run state and hardware specs, and can auto-sync on a schedule. Guest IPs that were already found by a network scan are merged in place (no duplicates).
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- A reachable **Proxmox VE** host (default API port `8006`)
|
|
||||||
- A **Proxmox API token** with the read-only **`PVEAuditor`** role (Datacenter → Permissions → API Tokens)
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
1. Click **Proxmox Import** in the left sidebar (below "Z-Wave Import")
|
|
||||||
2. Enter the host, port (default `8006`), and API token (`user@realm!tokenid` + secret) — or leave the token blank to use the server-configured one
|
|
||||||
3. Click **Test Connection** to verify reachability + token
|
|
||||||
4. Choose a target — **Pending section** or **Canvas directly** — then **Import to Pending** / **Fetch Inventory**
|
|
||||||
5. Select the devices from the grouped list (Hosts / Virtual Machines / LXC Containers) and click **Add N to Canvas**
|
|
||||||
|
|
||||||
### Node Types
|
|
||||||
|
|
||||||
| Type | Proxmox object | Icon |
|
|
||||||
|------|----------------|------|
|
|
||||||
| `proxmox` | Host / cluster member | Layers |
|
|
||||||
| `vm` | QEMU virtual machine | Box |
|
|
||||||
| `lxc` | LXC container | Container |
|
|
||||||
|
|
||||||
Each host is linked to its guests with a `virtual` edge. vCPU / RAM / disk are imported as node properties (hidden by default). Enable **auto-sync** from Settings once a server token is configured (`PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET`).
|
|
||||||
|
|
||||||
> **Full documentation:** [docs/proxmox-import.md](./docs/proxmox-import.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Live View (read-only public canvas)
|
|
||||||
|
|
||||||
Live View lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
|
|
||||||
|
|
||||||
### Activation
|
|
||||||
|
|
||||||
Add LIVEVIEW_KEY to your .env:
|
|
||||||
|
|
||||||
`LIVEVIEW_KEY=your-secret-key`
|
|
||||||
|
|
||||||
|
|
||||||
Then restart the backend:
|
|
||||||
|
|
||||||
`docker compose restart backend`
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
Use this URL to view your canvas:
|
|
||||||
|
|
||||||
http://<your-homelab-ip>/view?key=your-secret-key
|
|
||||||
|
|
||||||
The page shows your canvas in pan/zoom-only mode — no editing, no credentials needed. Clicking a node that has an IP opens it in a new tab.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Gethomepage Widget (read-only stats)
|
|
||||||
|
|
||||||
Homelable can expose a small JSON stats endpoint that [gethomepage](https://gethomepage.dev) consumes through its built-in `customapi` widget. Disabled by default.
|
|
||||||
|
|
||||||
### Activation
|
|
||||||
|
|
||||||
Add `HOMEPAGE_API_KEY` to your `.env`:
|
|
||||||
|
|
||||||
`HOMEPAGE_API_KEY=your-secret-key`
|
|
||||||
|
|
||||||
Restart the backend (`docker compose restart backend`).
|
|
||||||
|
|
||||||
### Endpoint
|
|
||||||
|
|
||||||
`GET /api/v1/stats/summary` — requires header `X-API-Key: your-secret-key`. Returns:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"nodes": 12,
|
|
||||||
"online": 9,
|
|
||||||
"offline": 2,
|
|
||||||
"unknown": 1,
|
|
||||||
"pending_devices": 3,
|
|
||||||
"zigbee_devices": 5,
|
|
||||||
"last_scan_at": "2026-05-14T10:00:00+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### gethomepage `services.yaml` snippet
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- Homelab:
|
|
||||||
- Homelable:
|
|
||||||
icon: mdi-lan
|
|
||||||
href: http://homelable.local:3000
|
|
||||||
widget:
|
|
||||||
type: customapi
|
|
||||||
url: http://homelable.local:8000/api/v1/stats/summary
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
X-API-Key: your-secret-key
|
|
||||||
mappings:
|
|
||||||
- field: nodes ; label: Nodes
|
|
||||||
- field: online ; label: Online
|
|
||||||
- field: offline ; label: Offline
|
|
||||||
- field: pending_devices ; label: Pending
|
|
||||||
- field: zigbee_devices ; label: Zigbee
|
|
||||||
- field: last_scan_at ; label: Last scan
|
|
||||||
```
|
|
||||||
|
|
||||||
The backend port (`8000`) must be reachable from your gethomepage container.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MCP Server (AI Integration) (optional)
|
|
||||||
|
|
||||||
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
Homelable can exposes a [Model Context Protocol](https://modelcontextprotocol.io) server so any MCP-compatible AI client (Claude Code, Claude Desktop, Open WebUI…) can read your homelab topology and act on it.
|
||||||
|
|
||||||
@@ -336,17 +109,11 @@ docker compose up -d mcp
|
|||||||
# MCP server is now listening on http://<your-homelab-ip>:8001
|
# MCP server is now listening on http://<your-homelab-ip>:8001
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Proxmox LXC / bare-metal (no Docker):** create the LXC via
|
|
||||||
> [community-scripts/ProxmoxVE](https://github.com/community-scripts/ProxmoxVE) (or any
|
|
||||||
> Debian/Ubuntu LXC), then inside it run `sudo bash scripts/lxc-mcp-install.sh`.
|
|
||||||
> Installs a `homelable-mcp` systemd service, prompts for `MCP_API_KEY` / `MCP_SERVICE_KEY`
|
|
||||||
> (auto-generated if you press Enter), and skips prompts if `mcp/.env` already exists.
|
|
||||||
|
|
||||||
**3. Configure your AI client:**
|
**3. Configure your AI client:**
|
||||||
|
|
||||||
**Claude Code** — run this command in your terminal:
|
**Claude Code** — run this command in your terminal:
|
||||||
```bash
|
```bash
|
||||||
claude mcp add --transport http homelable http://<your-homelab-ip>:8001/mcp/ \
|
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
|
||||||
--header "X-API-Key: mcp_sk_yourkey"
|
--header "X-API-Key: mcp_sk_yourkey"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -355,8 +122,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "http",
|
"type": "sse",
|
||||||
"url": "http://<your-homelab-ip>:8001/mcp/",
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-API-Key": "mcp_sk_yourkey"
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
}
|
}
|
||||||
@@ -370,8 +137,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "http",
|
"type": "sse",
|
||||||
"url": "http://<your-homelab-ip>:8001/mcp/",
|
"url": "http://<your-homelab-ip>:8001/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"X-API-Key": "mcp_sk_yourkey"
|
"X-API-Key": "mcp_sk_yourkey"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import CanvasState, Design, Edge, Node
|
from app.db.models import CanvasState, Edge, Node
|
||||||
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
@@ -17,57 +16,33 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
@router.get("", response_model=CanvasStateResponse)
|
||||||
async def load_canvas(
|
async def load_canvas(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> CanvasStateResponse:
|
||||||
design_id: str | None = Query(None, description="Design ID to load; uses first design if omitted"),
|
nodes = (await db.execute(select(Node))).scalars().all()
|
||||||
db: AsyncSession = Depends(get_db),
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
_: str = Depends(get_current_user),
|
state = await db.get(CanvasState, 1)
|
||||||
) -> CanvasStateResponse:
|
|
||||||
if design_id is None:
|
|
||||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
design_id = first.id if first else None
|
|
||||||
if design_id is None:
|
|
||||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
|
||||||
|
|
||||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
|
||||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
|
||||||
state = await db.get(CanvasState, design_id)
|
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||||
return CanvasStateResponse(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
viewport=viewport,
|
||||||
custom_style=state.custom_style if state else None,
|
|
||||||
# A CanvasState row exists only after a save (or explicit design create),
|
|
||||||
# so its presence marks an intentional canvas vs. a never-touched one.
|
|
||||||
initialized=state is not None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/save")
|
@router.post("/save")
|
||||||
async def save_canvas(
|
async def save_canvas(
|
||||||
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
body: CanvasSaveRequest, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool | str]:
|
) -> dict[str, bool]:
|
||||||
design_id = body.design_id
|
|
||||||
if design_id is None:
|
|
||||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
design_id = first.id if first else None
|
|
||||||
if design_id is None:
|
|
||||||
new_design = Design(id=str(uuid.uuid4()), name="Network Topology", design_type="network")
|
|
||||||
db.add(new_design)
|
|
||||||
await db.flush()
|
|
||||||
design_id = new_design.id
|
|
||||||
|
|
||||||
incoming_node_ids = {n.id for n in body.nodes}
|
incoming_node_ids = {n.id for n in body.nodes}
|
||||||
incoming_edge_ids = {e.id for e in body.edges}
|
incoming_edge_ids = {e.id for e in body.edges}
|
||||||
|
|
||||||
# Delete nodes removed from canvas (only within this design)
|
# Delete nodes removed from canvas
|
||||||
existing_nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
existing_nodes = (await db.execute(select(Node))).scalars().all()
|
||||||
for node in existing_nodes:
|
for node in existing_nodes:
|
||||||
if node.id not in incoming_node_ids:
|
if node.id not in incoming_node_ids:
|
||||||
await db.delete(node)
|
await db.delete(node)
|
||||||
|
|
||||||
# Delete edges removed from canvas (only within this design)
|
# Delete edges removed from canvas
|
||||||
existing_edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
existing_edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
for edge in existing_edges:
|
for edge in existing_edges:
|
||||||
if edge.id not in incoming_edge_ids:
|
if edge.id not in incoming_edge_ids:
|
||||||
await db.delete(edge)
|
await db.delete(edge)
|
||||||
@@ -77,33 +52,28 @@ async def save_canvas(
|
|||||||
# Upsert nodes
|
# Upsert nodes
|
||||||
for node_data in body.nodes:
|
for node_data in body.nodes:
|
||||||
db_node = await db.get(Node, node_data.id)
|
db_node = await db.get(Node, node_data.id)
|
||||||
payload = node_data.model_dump()
|
|
||||||
payload["design_id"] = design_id
|
|
||||||
if db_node:
|
if db_node:
|
||||||
for field, value in payload.items():
|
for field, value in node_data.model_dump().items():
|
||||||
setattr(db_node, field, value)
|
setattr(db_node, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Node(**payload))
|
db.add(Node(**node_data.model_dump()))
|
||||||
|
|
||||||
# Upsert edges
|
# Upsert edges
|
||||||
for edge_data in body.edges:
|
for edge_data in body.edges:
|
||||||
db_edge = await db.get(Edge, edge_data.id)
|
db_edge = await db.get(Edge, edge_data.id)
|
||||||
payload = edge_data.model_dump()
|
|
||||||
payload["design_id"] = design_id
|
|
||||||
if db_edge:
|
if db_edge:
|
||||||
for field, value in payload.items():
|
for field, value in edge_data.model_dump().items():
|
||||||
setattr(db_edge, field, value)
|
setattr(db_edge, field, value)
|
||||||
else:
|
else:
|
||||||
db.add(Edge(**payload))
|
db.add(Edge(**edge_data.model_dump()))
|
||||||
|
|
||||||
# Upsert viewport + custom style
|
# Upsert viewport
|
||||||
state = await db.get(CanvasState, design_id)
|
state = await db.get(CanvasState, 1)
|
||||||
if state:
|
if state:
|
||||||
state.viewport = body.viewport
|
state.viewport = body.viewport
|
||||||
state.custom_style = body.custom_style
|
|
||||||
state.saved_at = datetime.now(timezone.utc)
|
state.saved_at = datetime.now(timezone.utc)
|
||||||
else:
|
else:
|
||||||
db.add(CanvasState(design_id=design_id, viewport=body.viewport, custom_style=body.custom_style))
|
db.add(CanvasState(id=1, viewport=body.viewport))
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"saved": True}
|
return {"saved": True}
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.db.database import get_db
|
|
||||||
from app.db.models import CanvasState, Design, Edge, Node
|
|
||||||
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Node.type values that are canvas annotations rather than real devices. Kept in
|
|
||||||
# sync with the frontend (Sidebar counts, canvasSerializer types).
|
|
||||||
_GROUP_TYPE = "groupRect"
|
|
||||||
_TEXT_TYPE = "text"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[DesignResponse])
|
|
||||||
async def list_designs(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> list[DesignResponse]:
|
|
||||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
|
||||||
# One grouped query for all designs → node/group/text counts per design.
|
|
||||||
rows = (
|
|
||||||
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
|
|
||||||
).all()
|
|
||||||
counts: dict[str, dict[str, int]] = {}
|
|
||||||
for design_id, node_type, count in rows:
|
|
||||||
if design_id is None:
|
|
||||||
continue
|
|
||||||
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
|
|
||||||
if node_type == _GROUP_TYPE:
|
|
||||||
bucket["group"] += count
|
|
||||||
elif node_type == _TEXT_TYPE:
|
|
||||||
bucket["text"] += count
|
|
||||||
else:
|
|
||||||
bucket["node"] += count
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for d in designs:
|
|
||||||
resp = DesignResponse.model_validate(d)
|
|
||||||
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
|
|
||||||
resp.node_count = c["node"]
|
|
||||||
resp.group_count = c["group"]
|
|
||||||
resp.text_count = c["text"]
|
|
||||||
result.append(resp)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=DesignResponse, status_code=201)
|
|
||||||
async def create_design(
|
|
||||||
body: DesignCreate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> DesignResponse:
|
|
||||||
design = Design(name=body.name, design_type=body.design_type, icon=body.icon)
|
|
||||||
db.add(design)
|
|
||||||
await db.flush()
|
|
||||||
# Create empty canvas state for the new design
|
|
||||||
db.add(CanvasState(design_id=design.id))
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(design)
|
|
||||||
return DesignResponse.model_validate(design)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
|
|
||||||
async def copy_design(
|
|
||||||
source_id: str,
|
|
||||||
body: DesignCopy,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> DesignResponse:
|
|
||||||
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
|
|
||||||
source = await db.get(Design, source_id)
|
|
||||||
if not source:
|
|
||||||
raise HTTPException(404, "Source design not found")
|
|
||||||
|
|
||||||
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
|
|
||||||
db.add(new_design)
|
|
||||||
await db.flush()
|
|
||||||
|
|
||||||
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
|
|
||||||
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
|
|
||||||
|
|
||||||
# New id per source node so edges and parent links can be re-pointed at the copy.
|
|
||||||
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
|
|
||||||
|
|
||||||
# Columns we set explicitly or let the DB default — never copy verbatim.
|
|
||||||
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
|
|
||||||
for n in src_nodes:
|
|
||||||
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
|
|
||||||
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
|
|
||||||
await db.flush() # nodes must exist before we wire self-referential parent_id
|
|
||||||
|
|
||||||
# Second pass: re-point parent links inside the copy.
|
|
||||||
for n in src_nodes:
|
|
||||||
if n.parent_id and n.parent_id in id_map:
|
|
||||||
child = await db.get(Node, id_map[n.id])
|
|
||||||
if child:
|
|
||||||
child.parent_id = id_map[n.parent_id]
|
|
||||||
|
|
||||||
edge_skip = {"id", "design_id", "source", "target", "created_at"}
|
|
||||||
for e in src_edges:
|
|
||||||
# Skip edges whose endpoints aren't part of this design (dangling FKs).
|
|
||||||
if e.source not in id_map or e.target not in id_map:
|
|
||||||
continue
|
|
||||||
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
|
|
||||||
db.add(
|
|
||||||
Edge(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
design_id=new_design.id,
|
|
||||||
source=id_map[e.source],
|
|
||||||
target=id_map[e.target],
|
|
||||||
**cols,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
|
|
||||||
src_state = await db.get(CanvasState, source_id)
|
|
||||||
db.add(
|
|
||||||
CanvasState(
|
|
||||||
design_id=new_design.id,
|
|
||||||
viewport=src_state.viewport if src_state else {},
|
|
||||||
custom_style=src_state.custom_style if src_state else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(new_design)
|
|
||||||
return DesignResponse.model_validate(new_design)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{design_id}", response_model=DesignResponse)
|
|
||||||
async def update_design(
|
|
||||||
design_id: str,
|
|
||||||
body: DesignUpdate,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> DesignResponse:
|
|
||||||
design = await db.get(Design, design_id)
|
|
||||||
if not design:
|
|
||||||
raise HTTPException(404, "Design not found")
|
|
||||||
if body.name is not None:
|
|
||||||
design.name = body.name
|
|
||||||
if body.icon is not None:
|
|
||||||
design.icon = body.icon
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(design)
|
|
||||||
return DesignResponse.model_validate(design)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{design_id}", status_code=204)
|
|
||||||
async def delete_design(
|
|
||||||
design_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> None:
|
|
||||||
design = await db.get(Design, design_id)
|
|
||||||
if not design:
|
|
||||||
raise HTTPException(404, "Design not found")
|
|
||||||
# Count remaining designs — prevent deleting the last one
|
|
||||||
count = (await db.execute(select(Design))).scalars().all()
|
|
||||||
if len(count) <= 1:
|
|
||||||
raise HTTPException(400, "Cannot delete the only design")
|
|
||||||
# Delete associated canvas state, edges, nodes
|
|
||||||
cs = await db.get(CanvasState, design_id)
|
|
||||||
if cs:
|
|
||||||
await db.delete(cs)
|
|
||||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
|
||||||
for e in edges:
|
|
||||||
await db.delete(e)
|
|
||||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
|
||||||
for n in nodes:
|
|
||||||
await db.delete(n)
|
|
||||||
await db.delete(design)
|
|
||||||
await db.commit()
|
|
||||||
@@ -4,56 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import Design, Edge, Node
|
from app.db.models import Edge
|
||||||
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auto-handle helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def _abs_y(db: AsyncSession, node_id: str) -> float | None:
|
|
||||||
"""Resolve the approximate absolute canvas Y of a node.
|
|
||||||
|
|
||||||
Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so
|
|
||||||
that children inside containers are compared correctly against top-level nodes.
|
|
||||||
Returns None when the node is not found.
|
|
||||||
"""
|
|
||||||
node = await db.get(Node, node_id)
|
|
||||||
if node is None:
|
|
||||||
return None
|
|
||||||
y = node.pos_y
|
|
||||||
current = node
|
|
||||||
for _ in range(8):
|
|
||||||
if current.parent_id is None:
|
|
||||||
break
|
|
||||||
parent = await db.get(Node, current.parent_id)
|
|
||||||
if parent is None:
|
|
||||||
break
|
|
||||||
y += parent.pos_y
|
|
||||||
current = parent
|
|
||||||
return y
|
|
||||||
|
|
||||||
|
|
||||||
async def _auto_handles(
|
|
||||||
db: AsyncSession, source_id: str, target_id: str
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
"""Return (source_handle, target_handle) that reflect the upstream/downstream
|
|
||||||
relationship between two nodes.
|
|
||||||
|
|
||||||
- Source above target (lower Y value) → downstream flow: exit bottom, enter top
|
|
||||||
- Source below target → upstream flow: exit top, enter bottom
|
|
||||||
- Equal or unknown → default to bottom/top-t (most common topology direction)
|
|
||||||
"""
|
|
||||||
src_y = await _abs_y(db, source_id)
|
|
||||||
tgt_y = await _abs_y(db, target_id)
|
|
||||||
|
|
||||||
if src_y is None or tgt_y is None or src_y <= tgt_y:
|
|
||||||
return "bottom", "top-t"
|
|
||||||
return "top", "bottom-t"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[EdgeResponse])
|
@router.get("", response_model=list[EdgeResponse])
|
||||||
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
|
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
|
||||||
@@ -63,26 +18,7 @@ async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
|||||||
|
|
||||||
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=EdgeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
|
async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Edge:
|
||||||
data = body.model_dump()
|
edge = Edge(**body.model_dump())
|
||||||
# Same reconciliation as nodes: clients omitting design_id (MCP write tools)
|
|
||||||
# would create design_id=null edges that never render until a restart.
|
|
||||||
# Fall back to the first design so the edge attaches to a canvas.
|
|
||||||
if data.get("design_id") is None:
|
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
data["design_id"] = first_design.id if first_design else None
|
|
||||||
|
|
||||||
# Auto-assign source/target handles when the caller omits them.
|
|
||||||
# Compares the canvas Y positions of both nodes so that the edge always exits
|
|
||||||
# the upstream node's bottom and enters the downstream node's top (or vice versa
|
|
||||||
# for reverse flows), matching the UI convention for top-to-bottom topologies.
|
|
||||||
if data.get("source_handle") is None or data.get("target_handle") is None:
|
|
||||||
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
|
|
||||||
if data.get("source_handle") is None:
|
|
||||||
data["source_handle"] = auto_src
|
|
||||||
if data.get("target_handle") is None:
|
|
||||||
data["target_handle"] = auto_tgt
|
|
||||||
|
|
||||||
edge = Edge(**data)
|
|
||||||
db.add(edge)
|
db.add(edge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(edge)
|
await db.refresh(edge)
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import hmac
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.database import get_db
|
|
||||||
from app.db.models import CanvasState, Design, Edge, Node
|
|
||||||
from app.schemas.canvas import CanvasStateResponse
|
|
||||||
from app.schemas.edges import EdgeResponse
|
|
||||||
from app.schemas.nodes import NodeResponse
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class LiveViewConfigResponse(BaseModel):
|
|
||||||
"""Whether live view is enabled, plus the key (admin-only) to build share links."""
|
|
||||||
|
|
||||||
enabled: bool
|
|
||||||
key: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=LiveViewConfigResponse)
|
|
||||||
async def liveview_config(
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> LiveViewConfigResponse:
|
|
||||||
"""Authenticated: expose the configured live view key so the UI can build a
|
|
||||||
ready-to-use share link (e.g. /view?key=...&design=<id>).
|
|
||||||
|
|
||||||
Only reachable by a logged-in user — the key is never exposed publicly.
|
|
||||||
"""
|
|
||||||
key = settings.liveview_key or None
|
|
||||||
return LiveViewConfigResponse(enabled=bool(key), key=key)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CanvasStateResponse)
|
|
||||||
async def liveview_canvas(
|
|
||||||
key: str | None = Query(default=None),
|
|
||||||
design_id: str | None = Query(default=None, description="Design to show; uses first if omitted"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> CanvasStateResponse:
|
|
||||||
"""Read-only public canvas endpoint.
|
|
||||||
|
|
||||||
Disabled by default — requires LIVEVIEW_KEY to be set in .env.
|
|
||||||
Always returns 403 when disabled, regardless of the key provided.
|
|
||||||
"""
|
|
||||||
if not settings.liveview_key:
|
|
||||||
raise HTTPException(status_code=403, detail="Live view is disabled")
|
|
||||||
if not key or not hmac.compare_digest(key, settings.liveview_key):
|
|
||||||
raise HTTPException(status_code=403, detail="Invalid live view key")
|
|
||||||
|
|
||||||
if design_id is None:
|
|
||||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
design_id = first.id if first else None
|
|
||||||
if design_id is None:
|
|
||||||
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
|
|
||||||
|
|
||||||
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
|
|
||||||
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
|
|
||||||
state = await db.get(CanvasState, design_id)
|
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
|
||||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
|
||||||
return CanvasStateResponse(
|
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
|
||||||
viewport=viewport,
|
|
||||||
custom_style=custom_style,
|
|
||||||
)
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""Generic media upload/serve endpoint.
|
|
||||||
|
|
||||||
Images are stored on disk (see `Settings.media_dir`) with server-generated
|
|
||||||
UUID filenames — never in the DB, and the client filename is never trusted.
|
|
||||||
Upload/delete require auth; GET is public so plain <img> tags and the read-only
|
|
||||||
live view can load images (filenames are unguessable).
|
|
||||||
|
|
||||||
Currently used by the floor-plan feature; kept deliberately generic so future
|
|
||||||
raw-image uploads reuse the same endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
|
||||||
from fastapi.responses import FileResponse
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# content-type → extension. Also the allowlist of accepted uploads.
|
|
||||||
ALLOWED_TYPES: dict[str, str] = {
|
|
||||||
"image/png": ".png",
|
|
||||||
"image/jpeg": ".jpg",
|
|
||||||
"image/webp": ".webp",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
|
|
||||||
_MAGIC: dict[str, tuple[bytes, ...]] = {
|
|
||||||
".png": (b"\x89PNG\r\n\x1a\n",),
|
|
||||||
".jpg": (b"\xff\xd8\xff",),
|
|
||||||
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
|
|
||||||
}
|
|
||||||
|
|
||||||
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
||||||
|
|
||||||
# Only ever serve/delete files we created: 32 hex chars + known extension.
|
|
||||||
_NAME_RE = re.compile(r"[0-9a-f]{32}\.(png|jpg|webp)")
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_media_path(filename: str) -> Path:
|
|
||||||
"""Return the existing media file named `filename`, or raise 404.
|
|
||||||
|
|
||||||
`filename` is validated against `_NAME_RE` (no separators, no `..`), then
|
|
||||||
matched by name against the actual directory listing. The user string is
|
|
||||||
only ever compared with `==` against trusted `iterdir()` entries — it never
|
|
||||||
builds a path — so a crafted value cannot escape the media dir.
|
|
||||||
"""
|
|
||||||
if not _NAME_RE.fullmatch(filename):
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
||||||
base = settings.media_dir()
|
|
||||||
if base.is_dir():
|
|
||||||
for entry in base.iterdir():
|
|
||||||
if entry.name == filename and entry.is_file():
|
|
||||||
return entry
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
|
||||||
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
|
|
||||||
ext = ALLOWED_TYPES.get(file.content_type or "")
|
|
||||||
if ext is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail="Unsupported media type — PNG, JPEG, or WebP only",
|
|
||||||
)
|
|
||||||
# Read one byte past the cap so we can detect oversize without loading more.
|
|
||||||
data = await file.read(MAX_BYTES + 1)
|
|
||||||
if len(data) > MAX_BYTES:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
|
||||||
detail="File too large (max 10 MB)",
|
|
||||||
)
|
|
||||||
if not data:
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
|
|
||||||
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
||||||
detail="File content does not match its type",
|
|
||||||
)
|
|
||||||
|
|
||||||
media_dir = settings.media_dir()
|
|
||||||
media_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
name = f"{uuid.uuid4().hex}{ext}"
|
|
||||||
(media_dir / name).write_bytes(data)
|
|
||||||
return {"filename": name, "url": f"/api/v1/media/{name}"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{filename}")
|
|
||||||
async def get_media(filename: str) -> FileResponse:
|
|
||||||
# _resolve_media_path returns only an existing file, else raises 404.
|
|
||||||
return FileResponse(_resolve_media_path(filename))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
|
|
||||||
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
|
|
||||||
_resolve_media_path(filename).unlink()
|
|
||||||
@@ -4,55 +4,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db.models import Design, Node
|
from app.db.models import Node
|
||||||
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
|
||||||
from app.services.node_dedupe import find_duplicate_node
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auto-positioning helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Canvas grid used when placing nodes without explicit coordinates.
|
|
||||||
# Each slot is wide/tall enough that a normal node card fits without overlap.
|
|
||||||
_SLOT_W = 200.0
|
|
||||||
_SLOT_H = 100.0
|
|
||||||
_MAX_COLS = 7
|
|
||||||
|
|
||||||
|
|
||||||
async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[float, float]:
|
|
||||||
"""Return (x, y) for a new root-level node that doesn't collide with existing ones.
|
|
||||||
|
|
||||||
Snaps existing root nodes to a virtual grid and returns the first unoccupied
|
|
||||||
cell, scanning left-to-right then top-to-bottom.
|
|
||||||
"""
|
|
||||||
result = await db.execute(
|
|
||||||
select(Node.pos_x, Node.pos_y).where(
|
|
||||||
Node.design_id == design_id,
|
|
||||||
Node.parent_id.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
positions = list(result.all())
|
|
||||||
if not positions:
|
|
||||||
return 0.0, 0.0
|
|
||||||
|
|
||||||
# Snap each existing node to its true grid cell (negatives kept as-is). The
|
|
||||||
# search below only visits col >= 0 / row >= 0, so nodes parked in negative
|
|
||||||
# space never falsely block — or falsely free — a positive slot.
|
|
||||||
occupied: set[tuple[int, int]] = set()
|
|
||||||
for (px, py) in positions:
|
|
||||||
col = round(px / _SLOT_W)
|
|
||||||
row = round(py / _SLOT_H)
|
|
||||||
occupied.add((col, row))
|
|
||||||
|
|
||||||
for row in range(10_000):
|
|
||||||
for col in range(_MAX_COLS):
|
|
||||||
if (col, row) not in occupied:
|
|
||||||
return col * _SLOT_W, row * _SLOT_H
|
|
||||||
|
|
||||||
return 0.0, 0.0 # unreachable in practice
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[NodeResponse])
|
@router.get("", response_model=list[NodeResponse])
|
||||||
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
|
||||||
@@ -62,42 +18,7 @@ async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_cu
|
|||||||
|
|
||||||
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
|
||||||
data = body.model_dump()
|
node = Node(**body.model_dump())
|
||||||
# `force` bypasses the duplicate guard below; it is not a Node column.
|
|
||||||
force = data.pop("force", False)
|
|
||||||
# Attach to a design so the node lands on a canvas. Clients that don't send a
|
|
||||||
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
|
|
||||||
# nodes that exist in the DB but never render in the UI until a container
|
|
||||||
# restart reconciles them. Fall back to the first design, matching bulk-approve.
|
|
||||||
if data.get("design_id") is None:
|
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
data["design_id"] = first_design.id if first_design else None
|
|
||||||
|
|
||||||
# Reject a silent duplicate: a node with the same ip OR mac already on the
|
|
||||||
# target design. Scripts/MCP clients get a clear 409 (with the existing id)
|
|
||||||
# instead of a second card for the same host. Pass force=True to override.
|
|
||||||
if not force:
|
|
||||||
dup = await find_duplicate_node(db, data["design_id"], data.get("ip"), data.get("mac"))
|
|
||||||
if dup is not None:
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup)
|
|
||||||
|
|
||||||
# Auto-position: when pos_x / pos_y are omitted (None), find a free canvas
|
|
||||||
# slot so the new node doesn't land on top of an existing one.
|
|
||||||
# Child nodes (parent_id set) use (0, 0) relative to their parent instead.
|
|
||||||
if data["pos_x"] is None or data["pos_y"] is None:
|
|
||||||
if data.get("parent_id") is None:
|
|
||||||
auto_x, auto_y = await _find_free_position(db, data["design_id"])
|
|
||||||
if data["pos_x"] is None:
|
|
||||||
data["pos_x"] = auto_x
|
|
||||||
if data["pos_y"] is None:
|
|
||||||
data["pos_y"] = auto_y
|
|
||||||
else:
|
|
||||||
if data["pos_x"] is None:
|
|
||||||
data["pos_x"] = 0.0
|
|
||||||
if data["pos_y"] is None:
|
|
||||||
data["pos_y"] = 0.0
|
|
||||||
|
|
||||||
node = Node(**data)
|
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(node)
|
await db.refresh(node)
|
||||||
|
|||||||
@@ -1,512 +0,0 @@
|
|||||||
"""FastAPI router for Proxmox VE import + auto-sync config.
|
|
||||||
|
|
||||||
Fetches hosts/VMs/LXC from the Proxmox REST API and upserts them into the
|
|
||||||
pending inventory (same review→approve flow as scans and mesh imports).
|
|
||||||
|
|
||||||
Credentials: the API token comes from the request body when provided, else
|
|
||||||
falls back to the server-configured env token (``settings.proxmox_token_*``).
|
|
||||||
The token is never persisted by the app and never returned by any endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
||||||
from sqlalchemy import delete as sa_delete
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.scheduler import reschedule_proxmox_sync, set_proxmox_sync_enabled
|
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
|
||||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
|
||||||
from app.schemas.proxmox import (
|
|
||||||
ProxmoxConfig,
|
|
||||||
ProxmoxConnectionRequest,
|
|
||||||
ProxmoxEdgeOut,
|
|
||||||
ProxmoxImportPendingResponse,
|
|
||||||
ProxmoxImportResponse,
|
|
||||||
ProxmoxNodeOut,
|
|
||||||
ProxmoxSyncConfig,
|
|
||||||
ProxmoxTestConnectionResponse,
|
|
||||||
)
|
|
||||||
from app.schemas.scan import ScanRunResponse
|
|
||||||
from app.services.discovery_sources import add_source
|
|
||||||
from app.services.mac_utils import normalize_mac
|
|
||||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
|
||||||
from app.services.proxmox_service import (
|
|
||||||
build_proxmox_cluster_links,
|
|
||||||
build_proxmox_properties,
|
|
||||||
fetch_proxmox_inventory,
|
|
||||||
merge_proxmox_properties,
|
|
||||||
test_proxmox_connection,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
# Discovery sources for the two proxmox link shapes. Host→guest links render as
|
|
||||||
# 'virtual' edges; host↔host cluster links render as 'cluster' edges.
|
|
||||||
_PROXMOX_GUEST_SOURCE = "proxmox"
|
|
||||||
_PROXMOX_CLUSTER_SOURCE = "proxmox_cluster"
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_credentials(payload: ProxmoxConnectionRequest) -> tuple[str, str]:
|
|
||||||
"""Pick the API token: request body first, else server env config.
|
|
||||||
|
|
||||||
Raises HTTP 400 when neither carries a token.
|
|
||||||
"""
|
|
||||||
token_id = payload.token_id or settings.proxmox_token_id
|
|
||||||
token_secret = payload.token_secret or settings.proxmox_token_secret
|
|
||||||
if not token_id or not token_secret:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="No Proxmox API token provided and none configured on the server.",
|
|
||||||
)
|
|
||||||
return token_id, token_secret
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-connection", response_model=ProxmoxTestConnectionResponse)
|
|
||||||
async def test_connection_endpoint(
|
|
||||||
payload: ProxmoxConnectionRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ProxmoxTestConnectionResponse:
|
|
||||||
"""Validate host reachability + token before importing."""
|
|
||||||
token_id, token_secret = _resolve_credentials(payload)
|
|
||||||
connected, message = await test_proxmox_connection(
|
|
||||||
host=payload.host,
|
|
||||||
port=payload.port,
|
|
||||||
token_id=token_id,
|
|
||||||
token_secret=token_secret,
|
|
||||||
verify_tls=payload.verify_tls,
|
|
||||||
)
|
|
||||||
return ProxmoxTestConnectionResponse(connected=connected, message=message)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import", response_model=ProxmoxImportResponse)
|
|
||||||
async def import_proxmox(
|
|
||||||
payload: ProxmoxConnectionRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ProxmoxImportResponse:
|
|
||||||
"""Fetch the inventory and return nodes + edges ready for canvas drop."""
|
|
||||||
token_id, token_secret = _resolve_credentials(payload)
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
|
||||||
host=payload.host,
|
|
||||||
port=payload.port,
|
|
||||||
token_id=token_id,
|
|
||||||
token_secret=token_secret,
|
|
||||||
verify_tls=payload.verify_tls,
|
|
||||||
)
|
|
||||||
except ConnectionError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unexpected error during Proxmox import")
|
|
||||||
raise HTTPException(status_code=500, detail="Unexpected error during Proxmox import") from exc
|
|
||||||
|
|
||||||
nodes = [ProxmoxNodeOut(**n) for n in nodes_raw]
|
|
||||||
edges = [ProxmoxEdgeOut(**e) for e in edges_raw]
|
|
||||||
return ProxmoxImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
|
||||||
async def import_proxmox_to_pending(
|
|
||||||
payload: ProxmoxConnectionRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Queue a Proxmox pending import as a background scan run (kind=proxmox)."""
|
|
||||||
token_id, token_secret = _resolve_credentials(payload)
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="proxmox",
|
|
||||||
ranges=[f"{payload.host}:{payload.port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(
|
|
||||||
_background_proxmox_import,
|
|
||||||
run.id,
|
|
||||||
payload.host,
|
|
||||||
payload.port,
|
|
||||||
token_id,
|
|
||||||
token_secret,
|
|
||||||
payload.verify_tls,
|
|
||||||
)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
|
||||||
async def sync_proxmox_now(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Trigger an immediate Proxmox inventory sync using the server env config.
|
|
||||||
|
|
||||||
Same background flow as ``/import-pending`` but sources host + token from
|
|
||||||
``settings`` (env) rather than the request body — the manual counterpart to
|
|
||||||
the scheduled auto-sync job. Requires the env token to be configured.
|
|
||||||
"""
|
|
||||||
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot sync: no Proxmox host/token configured on the server.",
|
|
||||||
)
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="proxmox",
|
|
||||||
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(
|
|
||||||
_background_proxmox_import,
|
|
||||||
run.id,
|
|
||||||
settings.proxmox_host,
|
|
||||||
settings.proxmox_port,
|
|
||||||
settings.proxmox_token_id,
|
|
||||||
settings.proxmox_token_secret,
|
|
||||||
settings.proxmox_verify_tls,
|
|
||||||
)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
async def _background_proxmox_import(
|
|
||||||
run_id: str,
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token_id: str,
|
|
||||||
token_secret: str,
|
|
||||||
verify_tls: bool,
|
|
||||||
) -> None:
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_proxmox_inventory(
|
|
||||||
host=host,
|
|
||||||
port=port,
|
|
||||||
token_id=token_id,
|
|
||||||
token_secret=token_secret,
|
|
||||||
verify_tls=verify_tls,
|
|
||||||
)
|
|
||||||
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "done"
|
|
||||||
run.devices_found = result.device_count
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
run.error = _guest_visibility_advisory(nodes_raw)
|
|
||||||
await db.commit()
|
|
||||||
# Nudge the frontend to reload the inventory (same signal the IP scan
|
|
||||||
# emits) so imported/merged devices appear without a manual refresh.
|
|
||||||
from app.api.routes.status import broadcast_scan_update
|
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=result.device_count)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Proxmox import %s failed", run_id)
|
|
||||||
await db.rollback()
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "error"
|
|
||||||
run.error = str(exc)[:500]
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_visibility_advisory(nodes_raw: list[dict[str, Any]]) -> str | None:
|
|
||||||
"""Non-fatal advisory when hosts import but no VMs/LXC were visible.
|
|
||||||
|
|
||||||
A Proxmox API token that lacks ``VM.Audit`` sees empty ``qemu``/``lxc`` lists
|
|
||||||
(HTTP 200, no error), so only host nodes come through. The usual cause is a
|
|
||||||
privilege-separated token whose effective rights are the *intersection* with
|
|
||||||
the user's rights — granting PVEAuditor to the token alone is not enough when
|
|
||||||
the user has none. Surface that instead of a silent success.
|
|
||||||
"""
|
|
||||||
hosts = sum(1 for n in nodes_raw if n.get("type") == "proxmox")
|
|
||||||
guests = len(nodes_raw) - hosts
|
|
||||||
if hosts and not guests:
|
|
||||||
return (
|
|
||||||
f"Imported {hosts} host(s) but no VMs or LXC were visible to the API "
|
|
||||||
"token. Grant the PVEAuditor role at path '/' to BOTH the token and "
|
|
||||||
"the user (privilege-separated tokens get the intersection of token "
|
|
||||||
"and user rights), then re-import."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _persist_pending_import(
|
|
||||||
db: AsyncSession,
|
|
||||||
nodes_raw: list[dict[str, Any]],
|
|
||||||
edges_raw: list[dict[str, Any]],
|
|
||||||
) -> ProxmoxImportPendingResponse:
|
|
||||||
"""Upsert Proxmox nodes/edges into pending_devices + pending_device_links.
|
|
||||||
|
|
||||||
Two-tier identity (order matters):
|
|
||||||
1. Match an existing canvas Node or pending row by **IP** (merge into a
|
|
||||||
device previously found by a scan) — never duplicate.
|
|
||||||
2. Else match by synthetic ``ieee_address`` (``pve-...``).
|
|
||||||
|
|
||||||
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
|
|
||||||
"""
|
|
||||||
await dedupe_nodes_by_ieee(db)
|
|
||||||
|
|
||||||
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
|
|
||||||
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
|
|
||||||
|
|
||||||
pending_created = 0
|
|
||||||
pending_updated = 0
|
|
||||||
|
|
||||||
for n in nodes_raw:
|
|
||||||
ieee = n.get("ieee_address")
|
|
||||||
if not ieee:
|
|
||||||
continue
|
|
||||||
ip = n.get("ip")
|
|
||||||
mac = normalize_mac(n.get("mac"))
|
|
||||||
props = build_proxmox_properties(n)
|
|
||||||
|
|
||||||
# 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
|
|
||||||
# dedup key — a stopped VM has no IP but its configured NIC MAC still
|
|
||||||
# matches an ARP-scanned node). Refresh in place: merge properties, adopt
|
|
||||||
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
|
|
||||||
# Do NOT stomp user-set type/status.
|
|
||||||
node_filter = [Node.ieee_address == ieee]
|
|
||||||
if ip:
|
|
||||||
node_filter.append(Node.ip == ip)
|
|
||||||
if mac:
|
|
||||||
node_filter.append(Node.mac == mac)
|
|
||||||
existing_nodes = (
|
|
||||||
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
if existing_nodes:
|
|
||||||
for en in existing_nodes:
|
|
||||||
en.properties = merge_proxmox_properties(en.properties, props)
|
|
||||||
if not en.ieee_address:
|
|
||||||
en.ieee_address = ieee
|
|
||||||
if ip and not en.ip:
|
|
||||||
en.ip = ip
|
|
||||||
if mac and not en.mac:
|
|
||||||
en.mac = mac
|
|
||||||
en.hostname = en.hostname or n.get("hostname")
|
|
||||||
en.cpu_count = en.cpu_count or n.get("cpu_count")
|
|
||||||
en.ram_gb = en.ram_gb or n.get("ram_gb")
|
|
||||||
en.disk_gb = en.disk_gb or n.get("disk_gb")
|
|
||||||
# A cluster host needs one left + one right handle for the
|
|
||||||
# cluster edge endpoints (both default to 0).
|
|
||||||
if ieee in cluster_members:
|
|
||||||
en.left_handles = max(en.left_handles or 0, 1)
|
|
||||||
en.right_handles = max(en.right_handles or 0, 1)
|
|
||||||
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
|
|
||||||
pending_updated += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 2) Not on canvas — upsert the pending inventory row.
|
|
||||||
pending = await _find_pending(db, ieee, ip, mac)
|
|
||||||
if pending is None:
|
|
||||||
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
|
|
||||||
pending_created += 1
|
|
||||||
else:
|
|
||||||
_refresh_pending(pending, ieee, ip, mac, n, props)
|
|
||||||
pending_updated += 1
|
|
||||||
|
|
||||||
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ProxmoxImportPendingResponse(
|
|
||||||
pending_created=pending_created,
|
|
||||||
pending_updated=pending_updated,
|
|
||||||
links_recorded=links_recorded,
|
|
||||||
device_count=len(nodes_raw),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _find_pending(
|
|
||||||
db: AsyncSession, ieee: str, ip: str | None, mac: str | None
|
|
||||||
) -> PendingDevice | None:
|
|
||||||
filters = [PendingDevice.ieee_address == ieee]
|
|
||||||
if ip:
|
|
||||||
filters.append(PendingDevice.ip == ip)
|
|
||||||
if mac:
|
|
||||||
filters.append(PendingDevice.mac == mac)
|
|
||||||
return (
|
|
||||||
await db.execute(select(PendingDevice).where(or_(*filters)))
|
|
||||||
).scalars().first()
|
|
||||||
|
|
||||||
|
|
||||||
def _new_pending(
|
|
||||||
ieee: str,
|
|
||||||
ip: str | None,
|
|
||||||
mac: str | None,
|
|
||||||
n: dict[str, Any],
|
|
||||||
props: list[dict[str, Any]],
|
|
||||||
status: str,
|
|
||||||
) -> PendingDevice:
|
|
||||||
return PendingDevice(
|
|
||||||
ieee_address=ieee,
|
|
||||||
ip=ip,
|
|
||||||
mac=mac,
|
|
||||||
hostname=n.get("hostname"),
|
|
||||||
friendly_name=n.get("label"),
|
|
||||||
suggested_type=n.get("type"),
|
|
||||||
vendor=n.get("vendor"),
|
|
||||||
model=n.get("model"),
|
|
||||||
properties=props,
|
|
||||||
status=status,
|
|
||||||
discovery_source=_PROXMOX_GUEST_SOURCE,
|
|
||||||
discovery_sources=[_PROXMOX_GUEST_SOURCE],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _sources_after_merge(row: PendingDevice) -> list[str]:
|
|
||||||
"""Discovery sources for an inventory row after a Proxmox import merges in.
|
|
||||||
|
|
||||||
Must run BEFORE the ``pve-`` ieee is adopted onto the row, so it can tell
|
|
||||||
whether the row was originally a scanned device. Preserves the prior scan
|
|
||||||
origin — including legacy rows created before ``discovery_sources`` existed
|
|
||||||
(empty list) and possibly with a NULL ``discovery_source`` — so the IP tag
|
|
||||||
survives the merge. A row that carries an IP but was not itself a Proxmox
|
|
||||||
device (no ``pve-`` ieee) was found by a scan; keep an IP-scan source.
|
|
||||||
"""
|
|
||||||
sources = add_source(row.discovery_sources, row.discovery_source)
|
|
||||||
was_scanned = not (row.ieee_address or "").startswith("pve-")
|
|
||||||
if was_scanned and row.ip and not any(s in ("arp", "mdns") for s in sources):
|
|
||||||
sources = add_source(sources, "arp")
|
|
||||||
return add_source(sources, _PROXMOX_GUEST_SOURCE)
|
|
||||||
|
|
||||||
|
|
||||||
def _refresh_pending(
|
|
||||||
pending: PendingDevice,
|
|
||||||
ieee: str,
|
|
||||||
ip: str | None,
|
|
||||||
mac: str | None,
|
|
||||||
n: dict[str, Any],
|
|
||||||
props: list[dict[str, Any]],
|
|
||||||
) -> None:
|
|
||||||
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
|
||||||
pending.discovery_sources = _sources_after_merge(pending)
|
|
||||||
pending.ieee_address = pending.ieee_address or ieee
|
|
||||||
pending.ip = ip or pending.ip
|
|
||||||
pending.mac = pending.mac or mac
|
|
||||||
pending.hostname = n.get("hostname") or pending.hostname
|
|
||||||
pending.friendly_name = n.get("label") or pending.friendly_name
|
|
||||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
|
||||||
pending.vendor = n.get("vendor") or pending.vendor
|
|
||||||
pending.model = n.get("model") or pending.model
|
|
||||||
pending.properties = merge_proxmox_properties(list(pending.properties or []), props)
|
|
||||||
if pending.status == "approved":
|
|
||||||
# Approved earlier but the canvas Node is gone — revive so it reappears.
|
|
||||||
pending.status = "pending"
|
|
||||||
# hidden stays hidden.
|
|
||||||
|
|
||||||
|
|
||||||
async def _ensure_inventory_row(
|
|
||||||
db: AsyncSession,
|
|
||||||
ieee: str,
|
|
||||||
ip: str | None,
|
|
||||||
mac: str | None,
|
|
||||||
n: dict[str, Any],
|
|
||||||
props: list[dict[str, Any]],
|
|
||||||
approved: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Ensure an inventory row exists for a device already on a canvas, so it
|
|
||||||
shows in the inventory with an 'In N canvas' badge. Never changes status."""
|
|
||||||
inv = await _find_pending(db, ieee, ip, mac)
|
|
||||||
if inv is None:
|
|
||||||
db.add(_new_pending(ieee, ip, mac, n, props, status="approved" if approved else "pending"))
|
|
||||||
else:
|
|
||||||
# Compute sources before adopting the pve ieee (needs the pre-merge origin).
|
|
||||||
inv.discovery_sources = _sources_after_merge(inv)
|
|
||||||
inv.ieee_address = inv.ieee_address or ieee
|
|
||||||
inv.ip = ip or inv.ip
|
|
||||||
inv.mac = inv.mac or mac
|
|
||||||
inv.hostname = n.get("hostname") or inv.hostname
|
|
||||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
|
||||||
inv.properties = merge_proxmox_properties(list(inv.properties or []), props)
|
|
||||||
|
|
||||||
|
|
||||||
async def _replace_links(
|
|
||||||
db: AsyncSession,
|
|
||||||
edges_raw: list[dict[str, Any]],
|
|
||||||
cluster_pairs: list[tuple[str, str]],
|
|
||||||
) -> int:
|
|
||||||
"""Wipe all proxmox-source links and re-insert the freshly discovered set.
|
|
||||||
|
|
||||||
Two link shapes: host→guest (``proxmox`` → 'virtual' edges) and host↔host
|
|
||||||
(``proxmox_cluster`` → 'cluster' edges).
|
|
||||||
"""
|
|
||||||
await db.execute(
|
|
||||||
sa_delete(PendingDeviceLink).where(
|
|
||||||
PendingDeviceLink.discovery_source.in_([_PROXMOX_GUEST_SOURCE, _PROXMOX_CLUSTER_SOURCE])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
recorded = 0
|
|
||||||
seen: set[tuple[str, str]] = set()
|
|
||||||
|
|
||||||
def _add(src: str | None, tgt: str | None, source: str) -> None:
|
|
||||||
nonlocal recorded
|
|
||||||
if not src or not tgt or (src, tgt) in seen:
|
|
||||||
return
|
|
||||||
seen.add((src, tgt))
|
|
||||||
db.add(PendingDeviceLink(source_ieee=src, target_ieee=tgt, discovery_source=source))
|
|
||||||
recorded += 1
|
|
||||||
|
|
||||||
for e in edges_raw:
|
|
||||||
_add(e.get("source"), e.get("target"), _PROXMOX_GUEST_SOURCE)
|
|
||||||
for src, tgt in cluster_pairs:
|
|
||||||
_add(src, tgt, _PROXMOX_CLUSTER_SOURCE)
|
|
||||||
|
|
||||||
return recorded
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=ProxmoxConfig)
|
|
||||||
async def get_proxmox_config(_: str = Depends(get_current_user)) -> ProxmoxConfig:
|
|
||||||
"""Return non-secret Proxmox config. Never includes the token — only whether
|
|
||||||
one is configured on the server."""
|
|
||||||
return ProxmoxConfig(
|
|
||||||
host=settings.proxmox_host,
|
|
||||||
port=settings.proxmox_port,
|
|
||||||
verify_tls=settings.proxmox_verify_tls,
|
|
||||||
sync_enabled=settings.proxmox_sync_enabled,
|
|
||||||
sync_interval=settings.proxmox_sync_interval,
|
|
||||||
token_configured=bool(settings.proxmox_token_id and settings.proxmox_token_secret),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ProxmoxConfig)
|
|
||||||
async def save_proxmox_config(
|
|
||||||
payload: ProxmoxSyncConfig,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ProxmoxConfig:
|
|
||||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
|
||||||
|
|
||||||
This is the ONLY Proxmox config the app writes. Connection settings
|
|
||||||
(host, port, token, verify_tls) are env-only and are never accepted or
|
|
||||||
persisted here — enabling auto-sync requires host + token already set in the
|
|
||||||
server env, since the scheduled job reads them from there.
|
|
||||||
"""
|
|
||||||
if payload.sync_enabled and not (
|
|
||||||
settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret
|
|
||||||
):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot enable auto-sync: no Proxmox host/token configured in the server env.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
settings.proxmox_sync_enabled = payload.sync_enabled
|
|
||||||
settings.proxmox_sync_interval = payload.sync_interval
|
|
||||||
settings.save_overrides()
|
|
||||||
set_proxmox_sync_enabled(payload.sync_enabled)
|
|
||||||
if payload.sync_enabled:
|
|
||||||
reschedule_proxmox_sync(payload.sync_interval)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return await get_proxmox_config()
|
|
||||||
+20
-713
@@ -1,527 +1,59 @@
|
|||||||
import ipaddress
|
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Node, PendingDevice, ScanRun
|
||||||
from app.schemas.nodes import NodeCreate
|
from app.schemas.nodes import NodeCreate
|
||||||
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
|
||||||
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
|
from app.services.scanner import run_scan
|
||||||
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
|
||||||
from app.services.zigbee_service import (
|
|
||||||
build_zigbee_properties,
|
|
||||||
merge_zigbee_properties,
|
|
||||||
)
|
|
||||||
from app.services.zwave_service import build_zwave_properties
|
|
||||||
|
|
||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
|
||||||
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_tokens(ip: str | None) -> list[str]:
|
|
||||||
"""Split a Node/device ``ip`` field into individual addresses.
|
|
||||||
|
|
||||||
The canvas stores multiple addresses in one comma-separated string (e.g.
|
|
||||||
``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
|
|
||||||
scanned device against that field must compare per-address, not against the
|
|
||||||
whole string, or the device looks absent from the canvas (issue #258).
|
|
||||||
"""
|
|
||||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
|
||||||
|
|
||||||
|
|
||||||
def _is_wireless(node_type: str | None) -> bool:
|
|
||||||
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
|
||||||
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
|
||||||
|
|
||||||
|
|
||||||
def _wireless_properties(
|
|
||||||
node_type: str | None,
|
|
||||||
ieee: str | None,
|
|
||||||
vendor: str | None,
|
|
||||||
model: str | None,
|
|
||||||
lqi: int | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build the right property rows for a mesh device (Z-Wave has no LQI)."""
|
|
||||||
if node_type in _ZWAVE_TYPES:
|
|
||||||
return build_zwave_properties(ieee, vendor, model)
|
|
||||||
return build_zigbee_properties(ieee, vendor, model, lqi)
|
|
||||||
|
|
||||||
|
|
||||||
def build_mac_property(mac: str | None) -> list[dict[str, Any]]:
|
|
||||||
"""Build a NodeProperty list carrying a device MAC address.
|
|
||||||
|
|
||||||
Shape matches the frontend ``NodeProperty`` type
|
|
||||||
(``{key, value, icon, visible}``). Hidden by default — the user opts in to
|
|
||||||
showing it on the canvas card from the right panel. Returns an empty list
|
|
||||||
when no MAC is known.
|
|
||||||
"""
|
|
||||||
if not mac:
|
|
||||||
return []
|
|
||||||
return [{"key": "MAC", "value": mac, "icon": None, "visible": False}]
|
|
||||||
|
|
||||||
|
|
||||||
def merge_mac_property(
|
|
||||||
props: list[dict[str, Any]] | None, mac: str | None
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Append a MAC NodeProperty to ``props`` unless one is already present.
|
|
||||||
|
|
||||||
Preserves any user-supplied properties (and an existing MAC row's
|
|
||||||
visibility) untouched. Used on approve so the scanned MAC is not lost.
|
|
||||||
"""
|
|
||||||
out = [dict(p) for p in (props or [])]
|
|
||||||
if not mac or any(p.get("key") == "MAC" for p in out):
|
|
||||||
return out
|
|
||||||
out.append({"key": "MAC", "value": mac, "icon": None, "visible": False})
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
class BulkActionRequest(BaseModel):
|
|
||||||
device_ids: list[str]
|
|
||||||
# Target design for approved nodes. Falls back to the first design when
|
|
||||||
# omitted (keeps older clients working), but the UI should send the active
|
|
||||||
# design so approved devices land on the canvas the user is looking at.
|
|
||||||
design_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _check_port_ranges(v: list[str]) -> list[str]:
|
|
||||||
for r in v:
|
|
||||||
if not _valid_port_range(r.strip()):
|
|
||||||
raise ValueError(f"Invalid port range: {r!r}")
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
class ScanConfig(BaseModel):
|
class ScanConfig(BaseModel):
|
||||||
"""Persisted scan defaults (Options page). Deep-scan fields are optional."""
|
|
||||||
|
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
http_ranges: list[str] = []
|
interval_seconds: int
|
||||||
http_probe_enabled: bool = False
|
|
||||||
verify_tls: bool = False
|
|
||||||
|
|
||||||
@field_validator("ranges")
|
|
||||||
@classmethod
|
|
||||||
def validate_cidr(cls, v: list[str]) -> list[str]:
|
|
||||||
for r in v:
|
|
||||||
try:
|
|
||||||
ipaddress.ip_network(r, strict=False)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(f"Invalid CIDR range: {r!r}") from exc
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("http_ranges")
|
|
||||||
@classmethod
|
|
||||||
def validate_http_ranges(cls, v: list[str]) -> list[str]:
|
|
||||||
return _check_port_ranges(v)
|
|
||||||
|
|
||||||
|
|
||||||
class TriggerScanRequest(BaseModel):
|
|
||||||
"""Per-scan deep-scan overrides (scan dialog). None → use persisted default."""
|
|
||||||
|
|
||||||
http_ranges: list[str] | None = None
|
|
||||||
http_probe_enabled: bool | None = None
|
|
||||||
verify_tls: bool | None = None
|
|
||||||
|
|
||||||
@field_validator("http_ranges")
|
|
||||||
@classmethod
|
|
||||||
def validate_http_ranges(cls, v: list[str] | None) -> list[str] | None:
|
|
||||||
return None if v is None else _check_port_ranges(v)
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _background_scan(
|
async def _background_scan(run_id: str, ranges: list[str]) -> None:
|
||||||
run_id: str, ranges: list[str], deep_scan: DeepScanOptions | None = None
|
|
||||||
) -> None:
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
await run_scan(ranges, db, run_id)
|
||||||
await run_scan(ranges, db, run_id, deep_scan=deep_scan or DeepScanOptions())
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Scan run %s failed unexpectedly", run_id)
|
|
||||||
await db.rollback()
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run and run.status == "running":
|
|
||||||
run.status = "failed"
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_deep_scan(payload: TriggerScanRequest | None) -> DeepScanOptions:
|
|
||||||
"""Merge per-scan overrides over persisted settings defaults."""
|
|
||||||
p = payload or TriggerScanRequest()
|
|
||||||
return DeepScanOptions(
|
|
||||||
http_ranges=(
|
|
||||||
p.http_ranges if p.http_ranges is not None else settings.scanner_http_ranges
|
|
||||||
),
|
|
||||||
http_probe_enabled=(
|
|
||||||
p.http_probe_enabled
|
|
||||||
if p.http_probe_enabled is not None
|
|
||||||
else settings.scanner_http_probe_enabled
|
|
||||||
),
|
|
||||||
verify_tls=(
|
|
||||||
p.verify_tls if p.verify_tls is not None else settings.scanner_http_verify_tls
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger", response_model=ScanRunResponse)
|
@router.post("/trigger", response_model=ScanRunResponse)
|
||||||
async def trigger_scan(
|
async def trigger_scan(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
payload: TriggerScanRequest | None = None,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> ScanRun:
|
) -> ScanRun:
|
||||||
ranges = settings.scanner_ranges
|
ranges = settings.scanner_ranges
|
||||||
deep_scan = _resolve_deep_scan(payload)
|
|
||||||
run = ScanRun(status="running", ranges=ranges)
|
run = ScanRun(status="running", ranges=ranges)
|
||||||
db.add(run)
|
db.add(run)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(run)
|
await db.refresh(run)
|
||||||
background_tasks.add_task(_background_scan, run.id, ranges, deep_scan)
|
background_tasks.add_task(_background_scan, run.id, ranges)
|
||||||
return run
|
return run
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{run_id}/stop", response_model=dict)
|
|
||||||
async def stop_scan(
|
|
||||||
run_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, bool]:
|
|
||||||
try:
|
|
||||||
uuid.UUID(run_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid run_id format") from None
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if not run:
|
|
||||||
raise HTTPException(status_code=404, detail="Scan run not found")
|
|
||||||
if run.status != "running":
|
|
||||||
raise HTTPException(status_code=409, detail="Scan is not running")
|
|
||||||
request_cancel(run_id)
|
|
||||||
# Flip status eagerly so the UI reflects the stop immediately, instead of
|
|
||||||
# waiting for run_scan to reach its next cancellation checkpoint (which may
|
|
||||||
# be blocked inside a long nmap call). run_scan converges to the same state.
|
|
||||||
run.status = "cancelled"
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
return {"stopping": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
|
||||||
"""Pick the newest (max) or oldest (min) of a list of timestamps, or None."""
|
|
||||||
present = [v for v in values if v is not None]
|
|
||||||
if not present:
|
|
||||||
return None
|
|
||||||
return max(present) if newest else min(present)
|
|
||||||
|
|
||||||
|
|
||||||
async def _canvas_correlation(
|
|
||||||
db: AsyncSession, devices: list[PendingDevice]
|
|
||||||
) -> dict[str, dict[str, Any]]:
|
|
||||||
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
|
|
||||||
or ``ip``.
|
|
||||||
|
|
||||||
Returns, per device id: the number of distinct canvases (designs) it appears
|
|
||||||
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
|
||||||
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
|
||||||
(node counts are small for a homelab), so no N+1 per device.
|
|
||||||
|
|
||||||
IP matching is per-address: a node's ``ip`` may hold several comma-separated
|
|
||||||
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
|
|
||||||
the raw string. MAC is a stable identifier immune to such IP edits, so it is
|
|
||||||
matched too — cumulatively with ieee/ip (issue #258).
|
|
||||||
"""
|
|
||||||
if not devices:
|
|
||||||
return {}
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(
|
|
||||||
Node.ip,
|
|
||||||
Node.mac,
|
|
||||||
Node.ieee_address,
|
|
||||||
Node.design_id,
|
|
||||||
Node.created_at,
|
|
||||||
Node.last_scan,
|
|
||||||
Node.updated_at,
|
|
||||||
Node.last_seen,
|
|
||||||
).where(Node.design_id.isnot(None))
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
# Index matching nodes by ip token, mac and ieee so a device can look up any.
|
|
||||||
by_ip: dict[str, list[Any]] = {}
|
|
||||||
by_mac: dict[str, list[Any]] = {}
|
|
||||||
by_ieee: dict[str, list[Any]] = {}
|
|
||||||
for row in rows:
|
|
||||||
for tok in _ip_tokens(row.ip):
|
|
||||||
by_ip.setdefault(tok, []).append(row)
|
|
||||||
if row.mac:
|
|
||||||
by_mac.setdefault(row.mac, []).append(row)
|
|
||||||
if row.ieee_address:
|
|
||||||
by_ieee.setdefault(row.ieee_address, []).append(row)
|
|
||||||
|
|
||||||
info: dict[str, dict[str, Any]] = {}
|
|
||||||
for d in devices:
|
|
||||||
matched = []
|
|
||||||
if d.ieee_address:
|
|
||||||
matched += by_ieee.get(d.ieee_address, [])
|
|
||||||
if d.mac:
|
|
||||||
matched += by_mac.get(d.mac, [])
|
|
||||||
for tok in _ip_tokens(d.ip):
|
|
||||||
matched += by_ip.get(tok, [])
|
|
||||||
# De-duplicate nodes matched by more than one identifier.
|
|
||||||
matched = list({id(m): m for m in matched}.values())
|
|
||||||
designs = {m.design_id for m in matched}
|
|
||||||
info[d.id] = {
|
|
||||||
"canvas_count": len(designs),
|
|
||||||
"node_created_at": _agg([m.created_at for m in matched], newest=False),
|
|
||||||
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
|
|
||||||
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
|
|
||||||
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
|
|
||||||
|
|
||||||
async def _with_canvas_counts(
|
|
||||||
db: AsyncSession, devices: list[PendingDevice]
|
|
||||||
) -> list[PendingDevice]:
|
|
||||||
"""Attach transient canvas count + linked-node timestamps for the response."""
|
|
||||||
info = await _canvas_correlation(db, devices)
|
|
||||||
for d in devices:
|
|
||||||
meta = info.get(d.id, {})
|
|
||||||
d.canvas_count = meta.get("canvas_count", 0)
|
|
||||||
d.node_created_at = meta.get("node_created_at")
|
|
||||||
d.node_last_scan = meta.get("node_last_scan")
|
|
||||||
d.node_last_modified = meta.get("node_last_modified")
|
|
||||||
d.node_last_seen = meta.get("node_last_seen")
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
@router.get("/pending", response_model=list[PendingDeviceResponse])
|
||||||
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_pending(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
# Inventory: every scanned device except the user-hidden ones. Approved devices
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "pending"))
|
||||||
# stay listed so they keep showing with a canvas-presence badge.
|
return list(result.scalars().all())
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status != "hidden"))
|
|
||||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/pending", response_model=dict)
|
|
||||||
async def clear_pending(
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, int]:
|
|
||||||
from sqlalchemy import delete as sa_delete
|
|
||||||
result = await db.execute(sa_delete(PendingDevice).where(PendingDevice.status == "pending"))
|
|
||||||
await db.commit()
|
|
||||||
return {"deleted": result.rowcount}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
@router.get("/hidden", response_model=list[PendingDeviceResponse])
|
||||||
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
async def list_hidden(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[PendingDevice]:
|
||||||
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
result = await db.execute(select(PendingDevice).where(PendingDevice.status == "hidden"))
|
||||||
return await _with_canvas_counts(db, list(result.scalars().all()))
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/bulk-approve", response_model=dict)
|
|
||||||
async def bulk_approve_devices(
|
|
||||||
payload: BulkActionRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
# Repair any legacy same-canvas duplicate nodes before placing more.
|
|
||||||
await dedupe_nodes_by_ieee(db)
|
|
||||||
|
|
||||||
# Target the design the user is on; fall back to the first design.
|
|
||||||
default_design_id = payload.design_id
|
|
||||||
if default_design_id is None:
|
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
default_design_id = first_design.id if first_design else None
|
|
||||||
|
|
||||||
# Accept every selected device that isn't user-hidden. We intentionally do NOT
|
|
||||||
# filter on status == "pending": a device's status is global, but canvas
|
|
||||||
# membership is per-design. A device approved onto another canvas (or whose
|
|
||||||
# node was later deleted) must still be placeable on THIS design. Duplicates
|
|
||||||
# are guarded per-design below, not by the global status flag.
|
|
||||||
result = await db.execute(
|
|
||||||
select(PendingDevice).where(
|
|
||||||
PendingDevice.id.in_(payload.device_ids),
|
|
||||||
PendingDevice.status != "hidden",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
devices = result.scalars().all()
|
|
||||||
|
|
||||||
# What already sits on the target canvas, so we skip devices already placed
|
|
||||||
# here (by ip, mac or ieee_address) instead of creating duplicate nodes. We
|
|
||||||
# map to the existing node id so the skip report can point the user at it. A
|
|
||||||
# value may be a Node still pending flush (in-batch duplicate) — resolved to
|
|
||||||
# its id after the flush below. IPs are indexed per comma-separated token so
|
|
||||||
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
|
|
||||||
# 192.168.1.5 (issue #258).
|
|
||||||
existing = (
|
|
||||||
await db.execute(
|
|
||||||
select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
|
|
||||||
Node.design_id == default_design_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
placed_ips: dict[str, Any] = {
|
|
||||||
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
|
|
||||||
}
|
|
||||||
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
|
|
||||||
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
|
|
||||||
|
|
||||||
created_nodes: list[Node] = []
|
|
||||||
approved_devices: list[PendingDevice] = []
|
|
||||||
skipped_devices: list[dict[str, Any]] = []
|
|
||||||
for device in devices:
|
|
||||||
# Record which identifier collided so the caller can explain each skip
|
|
||||||
# (and, for existing on-canvas nodes, link to the node already there).
|
|
||||||
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
|
|
||||||
if ip_hit is not None:
|
|
||||||
skipped_devices.append({
|
|
||||||
"device_id": device.id,
|
|
||||||
"label": device.hostname or device.friendly_name or device.ip or "device",
|
|
||||||
"match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
if device.ieee_address is not None and device.ieee_address in placed_ieee:
|
|
||||||
skipped_devices.append({
|
|
||||||
"device_id": device.id,
|
|
||||||
"label": device.hostname or device.friendly_name or device.ieee_address or "device",
|
|
||||||
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
if device.mac is not None and device.mac in placed_mac:
|
|
||||||
skipped_devices.append({
|
|
||||||
"device_id": device.id,
|
|
||||||
"label": device.hostname or device.friendly_name or device.mac or "device",
|
|
||||||
"match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
device.status = "approved"
|
|
||||||
node_type = device.suggested_type or "generic"
|
|
||||||
is_wireless = _is_wireless(node_type)
|
|
||||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
|
||||||
node = Node(
|
|
||||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
|
||||||
type=node_type,
|
|
||||||
ip=device.ip,
|
|
||||||
mac=device.mac,
|
|
||||||
hostname=device.hostname,
|
|
||||||
status="online" if is_wireless else "unknown",
|
|
||||||
services=device.services or [],
|
|
||||||
ieee_address=device.ieee_address,
|
|
||||||
properties=_wireless_properties(
|
|
||||||
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
|
||||||
) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
|
|
||||||
# Default to ping so the status checker actually polls the new node.
|
|
||||||
# Without this the scheduler skips it (check_method NULL → no check).
|
|
||||||
check_method="none" if is_wireless else ("ping" if device.ip else None),
|
|
||||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
|
||||||
left_handles=1 if cluster_host else 0,
|
|
||||||
right_handles=1 if cluster_host else 0,
|
|
||||||
design_id=default_design_id,
|
|
||||||
)
|
|
||||||
db.add(node)
|
|
||||||
created_nodes.append(node)
|
|
||||||
approved_devices.append(device)
|
|
||||||
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
|
|
||||||
# not placed twice on the same canvas. Store the Node so a later in-batch
|
|
||||||
# skip can resolve to its id after flush.
|
|
||||||
for tok in _ip_tokens(device.ip):
|
|
||||||
placed_ips[tok] = node
|
|
||||||
if device.mac:
|
|
||||||
placed_mac[device.mac] = node
|
|
||||||
if device.ieee_address:
|
|
||||||
placed_ieee[device.ieee_address] = node
|
|
||||||
await db.flush() # populates node.id from Python-side default before reading
|
|
||||||
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
|
|
||||||
node_ids = [n.id for n in created_nodes]
|
|
||||||
approved_device_ids = [d.id for d in approved_devices]
|
|
||||||
|
|
||||||
# Resolve each skip's existing-node reference to a concrete id now that any
|
|
||||||
# in-batch nodes have been flushed, and expose it under a clean key.
|
|
||||||
for entry in skipped_devices:
|
|
||||||
ref = entry.pop("_ref")
|
|
||||||
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
|
|
||||||
|
|
||||||
all_edges: list[dict[str, str]] = []
|
|
||||||
for device in approved_devices:
|
|
||||||
all_edges.extend(
|
|
||||||
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
return {
|
|
||||||
"approved": len(node_ids),
|
|
||||||
"node_ids": node_ids,
|
|
||||||
"device_ids": approved_device_ids,
|
|
||||||
"edges_created": len(all_edges),
|
|
||||||
"edges": all_edges,
|
|
||||||
"skipped": len(payload.device_ids) - len(node_ids),
|
|
||||||
"skipped_devices": skipped_devices,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/bulk-hide", response_model=dict)
|
|
||||||
async def bulk_hide_devices(
|
|
||||||
payload: BulkActionRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = await db.execute(
|
|
||||||
select(PendingDevice).where(
|
|
||||||
PendingDevice.id.in_(payload.device_ids),
|
|
||||||
PendingDevice.status == "pending",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
devices = result.scalars().all()
|
|
||||||
for device in devices:
|
|
||||||
device.status = "hidden"
|
|
||||||
await db.commit()
|
|
||||||
return {"hidden": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/restore", response_model=dict)
|
|
||||||
async def restore_device(
|
|
||||||
device_id: str,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
device = await db.get(PendingDevice, device_id)
|
|
||||||
if not device:
|
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
|
||||||
if device.status != "hidden":
|
|
||||||
raise HTTPException(status_code=409, detail="Device is not hidden")
|
|
||||||
device.status = "pending"
|
|
||||||
await db.commit()
|
|
||||||
return {"restored": True, "device_id": device_id}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/bulk-restore", response_model=dict)
|
|
||||||
async def bulk_restore_devices(
|
|
||||||
payload: BulkActionRequest,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
result = await db.execute(
|
|
||||||
select(PendingDevice).where(
|
|
||||||
PendingDevice.id.in_(payload.device_ids),
|
|
||||||
PendingDevice.status == "hidden",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
devices = result.scalars().all()
|
|
||||||
for device in devices:
|
|
||||||
device.status = "pending"
|
|
||||||
await db.commit()
|
|
||||||
return {"restored": len(devices), "skipped": len(payload.device_ids) - len(devices)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/approve", response_model=dict)
|
@router.post("/pending/{device_id}/approve", response_model=dict)
|
||||||
@@ -531,220 +63,14 @@ async def approve_device(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
# Determine target design
|
|
||||||
node_design_id = node_data.design_id
|
|
||||||
if node_design_id is None:
|
|
||||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
|
||||||
node_design_id = first.id if first else None
|
|
||||||
|
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if not device:
|
if device:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
|
||||||
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
|
||||||
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
|
|
||||||
# design must therefore work even when the device already sits on another
|
|
||||||
# one (mirroring bulk_approve_devices, which deliberately does not filter on
|
|
||||||
# status == "pending"). Same-design duplicates are caught by the per-design
|
|
||||||
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
|
|
||||||
# device is off-limits here.
|
|
||||||
if device.status == "hidden":
|
|
||||||
raise HTTPException(status_code=409, detail="Device is hidden")
|
|
||||||
wireless = _is_wireless(node_data.type)
|
|
||||||
|
|
||||||
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
|
|
||||||
# again automatically: the user might genuinely want a second card, or might
|
|
||||||
# be re-approving by mistake. Reject with 409 + the existing node so the UI
|
|
||||||
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
|
|
||||||
# hosts. force=True (set after the user confirms) skips this and creates it.
|
|
||||||
# The same device on a *different* design is valid (one Node per canvas), so
|
|
||||||
# this is scoped to node_design_id.
|
|
||||||
if not node_data.force:
|
|
||||||
conflict = await find_duplicate_node(
|
|
||||||
db, node_design_id,
|
|
||||||
node_data.ip or device.ip,
|
|
||||||
node_data.mac or device.mac,
|
|
||||||
ieee=device.ieee_address,
|
|
||||||
)
|
|
||||||
if conflict is not None:
|
|
||||||
raise HTTPException(status_code=409, detail=conflict)
|
|
||||||
|
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
node = Node(**node_data.model_dump())
|
||||||
# fall back to whatever the approve payload carried.
|
|
||||||
_mac = device.mac or node_data.mac
|
|
||||||
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
|
||||||
node = Node(
|
|
||||||
label=node_data.label,
|
|
||||||
type=node_data.type,
|
|
||||||
ip=node_data.ip,
|
|
||||||
mac=_mac,
|
|
||||||
hostname=node_data.hostname,
|
|
||||||
status="online" if wireless else node_data.status,
|
|
||||||
services=node_data.services or [],
|
|
||||||
ieee_address=device.ieee_address,
|
|
||||||
properties=_wireless_properties(
|
|
||||||
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
|
||||||
) if wireless else merge_mac_property(
|
|
||||||
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
|
|
||||||
_mac,
|
|
||||||
),
|
|
||||||
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
|
|
||||||
check_target=None if wireless else node_data.check_target,
|
|
||||||
# Cluster hosts get side handles for their host↔host cluster edge.
|
|
||||||
left_handles=1 if cluster_host else 0,
|
|
||||||
right_handles=1 if cluster_host else 0,
|
|
||||||
design_id=node_design_id,
|
|
||||||
)
|
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.flush()
|
|
||||||
node_id = node.id
|
|
||||||
|
|
||||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {"approved": True, "node_id": node.id}
|
||||||
"approved": True,
|
return {"approved": False}
|
||||||
"node_id": node_id,
|
|
||||||
"edges_created": len(edges),
|
|
||||||
"edges": edges,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
|
|
||||||
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
|
|
||||||
|
|
||||||
Such a host needs one left + one right handle for the cluster edge endpoints
|
|
||||||
(both default to 0). Checked at approve time, before the link is consumed by
|
|
||||||
``_resolve_pending_links_for_ieee``.
|
|
||||||
"""
|
|
||||||
if not ieee:
|
|
||||||
return False
|
|
||||||
found = (
|
|
||||||
await db.execute(
|
|
||||||
select(PendingDeviceLink.id)
|
|
||||||
.where(
|
|
||||||
PendingDeviceLink.discovery_source == "proxmox_cluster",
|
|
||||||
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
).scalar()
|
|
||||||
return found is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_pending_links_for_ieee(
|
|
||||||
db: AsyncSession, ieee: str | None, design_id: str | None
|
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""Materialize edges for any pending_device_links involving ``ieee`` on the
|
|
||||||
canvas identified by ``design_id``.
|
|
||||||
|
|
||||||
For each link where the other endpoint already exists as a Node *on this
|
|
||||||
design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
|
|
||||||
Edge. Links are **never** deleted here: they describe the discovered mesh /
|
|
||||||
cluster topology and are wiped+reinserted wholesale on the next import
|
|
||||||
(zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
|
|
||||||
onto a second canvas with their edges intact.
|
|
||||||
"""
|
|
||||||
if not ieee:
|
|
||||||
return []
|
|
||||||
|
|
||||||
links_q = await db.execute(
|
|
||||||
select(PendingDeviceLink).where(
|
|
||||||
(PendingDeviceLink.source_ieee == ieee)
|
|
||||||
| (PendingDeviceLink.target_ieee == ieee)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
links = list(links_q.scalars().all())
|
|
||||||
if not links:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Map every relevant ieee → Node *on the target design* (single query).
|
|
||||||
# Scoping by design is what makes a re-approve onto a second canvas link the
|
|
||||||
# nodes of THAT canvas, not stale nodes left on another one.
|
|
||||||
other_ieees = {
|
|
||||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
|
||||||
for link in links
|
|
||||||
}
|
|
||||||
other_ieees.add(ieee)
|
|
||||||
nodes_q = await db.execute(
|
|
||||||
select(Node).where(
|
|
||||||
Node.ieee_address.in_(other_ieees),
|
|
||||||
Node.design_id == design_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
|
||||||
|
|
||||||
self_node = by_ieee.get(ieee)
|
|
||||||
if self_node is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Pre-fetch existing edges between these node ids so we don't create dups
|
|
||||||
# if the user re-approves a device or had drawn the link manually.
|
|
||||||
candidate_node_ids = [n.id for n in by_ieee.values()]
|
|
||||||
existing_q = await db.execute(
|
|
||||||
select(Edge).where(
|
|
||||||
Edge.source.in_(candidate_node_ids),
|
|
||||||
Edge.target.in_(candidate_node_ids),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
existing_pairs = {(e.source, e.target) for e in existing_q.scalars().all()}
|
|
||||||
|
|
||||||
created: list[dict[str, str]] = []
|
|
||||||
for link in links:
|
|
||||||
other_ieee = (
|
|
||||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
|
||||||
)
|
|
||||||
other_node = by_ieee.get(other_ieee)
|
|
||||||
if other_node is None:
|
|
||||||
continue
|
|
||||||
if link.source_ieee == ieee:
|
|
||||||
src_id, tgt_id = self_node.id, other_node.id
|
|
||||||
else:
|
|
||||||
src_id, tgt_id = other_node.id, self_node.id
|
|
||||||
# Skip if either direction already exists on this design (re-approve or
|
|
||||||
# a manually drawn link). The link row is kept for other designs.
|
|
||||||
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
|
||||||
continue
|
|
||||||
# Edge lands on the design we're approving into (the nodes above are
|
|
||||||
# already scoped to it).
|
|
||||||
edge_design_id = design_id or (self_node.design_id if self_node else None)
|
|
||||||
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
|
|
||||||
# (the canonical stored form — the save path normalizes '<side>-t' → the
|
|
||||||
# bare source id, and React Flow resolves the bare id to that side). A
|
|
||||||
# '-t' target id does not resolve here and RF falls back to the top
|
|
||||||
# handle, so never emit one.
|
|
||||||
# proxmox → 'virtual' host→guest, vertical (bottom → top)
|
|
||||||
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
|
|
||||||
# anything else → 'iot' mesh link, vertical
|
|
||||||
if link.discovery_source == "proxmox":
|
|
||||||
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
|
|
||||||
elif link.discovery_source == "proxmox_cluster":
|
|
||||||
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
|
|
||||||
else:
|
|
||||||
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
|
|
||||||
edge = Edge(
|
|
||||||
source=src_id,
|
|
||||||
target=tgt_id,
|
|
||||||
type=edge_type,
|
|
||||||
source_handle=src_handle,
|
|
||||||
target_handle=tgt_handle,
|
|
||||||
design_id=edge_design_id,
|
|
||||||
)
|
|
||||||
db.add(edge)
|
|
||||||
await db.flush()
|
|
||||||
existing_pairs.add((src_id, tgt_id))
|
|
||||||
# Return the edge's type + handles so the client injects it faithfully
|
|
||||||
# (a cluster edge must keep its right→left handles, not the iot default).
|
|
||||||
created.append({
|
|
||||||
"id": edge.id,
|
|
||||||
"source": src_id,
|
|
||||||
"target": tgt_id,
|
|
||||||
"type": edge_type,
|
|
||||||
"source_handle": src_handle,
|
|
||||||
"target_handle": tgt_handle,
|
|
||||||
})
|
|
||||||
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/pending/{device_id}/hide")
|
@router.post("/pending/{device_id}/hide")
|
||||||
@@ -752,8 +78,7 @@ async def hide_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if not device:
|
if device:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
|
||||||
device.status = "hidden"
|
device.status = "hidden"
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"hidden": True}
|
return {"hidden": True}
|
||||||
@@ -764,8 +89,7 @@ async def ignore_device(
|
|||||||
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
device_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if not device:
|
if device:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
|
||||||
await db.delete(device)
|
await db.delete(device)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {"ignored": True}
|
return {"ignored": True}
|
||||||
@@ -781,33 +105,16 @@ async def list_runs(db: AsyncSession = Depends(get_db), _: str = Depends(get_cur
|
|||||||
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
async def get_scan_config(_: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
return ScanConfig(
|
return ScanConfig(
|
||||||
ranges=settings.scanner_ranges,
|
ranges=settings.scanner_ranges,
|
||||||
http_ranges=settings.scanner_http_ranges,
|
interval_seconds=settings.status_checker_interval,
|
||||||
http_probe_enabled=settings.scanner_http_probe_enabled,
|
|
||||||
verify_tls=settings.scanner_http_verify_tls,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ScanConfig)
|
@router.post("/config", response_model=ScanConfig)
|
||||||
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
async def update_scan_config(payload: ScanConfig, _: str = Depends(get_current_user)) -> ScanConfig:
|
||||||
previous = (
|
|
||||||
settings.scanner_ranges,
|
|
||||||
settings.scanner_http_ranges,
|
|
||||||
settings.scanner_http_probe_enabled,
|
|
||||||
settings.scanner_http_verify_tls,
|
|
||||||
)
|
|
||||||
settings.scanner_ranges = payload.ranges
|
|
||||||
settings.scanner_http_ranges = payload.http_ranges
|
|
||||||
settings.scanner_http_probe_enabled = payload.http_probe_enabled
|
|
||||||
settings.scanner_http_verify_tls = payload.verify_tls
|
|
||||||
try:
|
try:
|
||||||
|
settings.scanner_ranges = payload.ranges
|
||||||
|
settings.status_checker_interval = payload.interval_seconds
|
||||||
settings.save_overrides()
|
settings.save_overrides()
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
(
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
settings.scanner_ranges,
|
|
||||||
settings.scanner_http_ranges,
|
|
||||||
settings.scanner_http_probe_enabled,
|
|
||||||
settings.scanner_http_verify_tls,
|
|
||||||
) = previous
|
|
||||||
logger.error("Failed to save scan config: %s", exc)
|
|
||||||
raise HTTPException(status_code=500, detail="Failed to save scan config") from exc
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
"""App-level settings (status checker interval, etc.)."""
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
class AppSettings(BaseModel):
|
|
||||||
interval_seconds: int
|
|
||||||
service_check_enabled: bool = False
|
|
||||||
service_check_interval: int = Field(default=300, ge=30)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=AppSettings)
|
|
||||||
async def get_settings(_: str = Depends(get_current_user)) -> AppSettings:
|
|
||||||
return AppSettings(
|
|
||||||
interval_seconds=settings.status_checker_interval,
|
|
||||||
service_check_enabled=settings.service_check_enabled,
|
|
||||||
service_check_interval=settings.service_check_interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=AppSettings)
|
|
||||||
async def update_settings(
|
|
||||||
payload: AppSettings, _: str = Depends(get_current_user)
|
|
||||||
) -> AppSettings:
|
|
||||||
try:
|
|
||||||
settings.status_checker_interval = payload.interval_seconds
|
|
||||||
settings.service_check_enabled = payload.service_check_enabled
|
|
||||||
settings.service_check_interval = payload.service_check_interval
|
|
||||||
settings.save_overrides()
|
|
||||||
# Apply the service-check schedule live.
|
|
||||||
set_service_checks_enabled(payload.service_check_enabled)
|
|
||||||
if payload.service_check_enabled:
|
|
||||||
reschedule_service_checks(payload.service_check_interval)
|
|
||||||
return payload
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import hmac
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.database import get_db
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _check_key(x_api_key: str | None) -> None:
|
|
||||||
if not settings.homepage_api_key:
|
|
||||||
raise HTTPException(status_code=403, detail="Stats endpoint is disabled")
|
|
||||||
if not x_api_key or not hmac.compare_digest(x_api_key, settings.homepage_api_key):
|
|
||||||
raise HTTPException(status_code=403, detail="Invalid API key")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary")
|
|
||||||
async def summary(
|
|
||||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
) -> dict[str, object]:
|
|
||||||
"""Read-only stats payload for the gethomepage `customapi` widget.
|
|
||||||
|
|
||||||
Disabled unless HOMEPAGE_API_KEY is set. Caller must send the same
|
|
||||||
value in the `X-API-Key` header.
|
|
||||||
"""
|
|
||||||
_check_key(x_api_key)
|
|
||||||
|
|
||||||
status_rows = (
|
|
||||||
await db.execute(select(Node.status, func.count()).group_by(Node.status))
|
|
||||||
).all()
|
|
||||||
counts = {row[0]: row[1] for row in status_rows}
|
|
||||||
|
|
||||||
pending = (
|
|
||||||
await db.execute(
|
|
||||||
select(func.count())
|
|
||||||
.select_from(PendingDevice)
|
|
||||||
.where(PendingDevice.status == "pending")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
zigbee = (
|
|
||||||
await db.execute(
|
|
||||||
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
last_scan_at = (
|
|
||||||
await db.execute(select(func.max(ScanRun.finished_at)))
|
|
||||||
).scalar_one()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"nodes": sum(counts.values()),
|
|
||||||
"online": counts.get("online", 0),
|
|
||||||
"offline": counts.get("offline", 0),
|
|
||||||
"unknown": counts.get("unknown", 0),
|
|
||||||
"pending_devices": pending,
|
|
||||||
"zigbee_devices": zigbee,
|
|
||||||
"last_scan_at": last_scan_at.isoformat() if last_scan_at else None,
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import contextlib
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
@@ -11,12 +10,6 @@ router = APIRouter()
|
|||||||
_connections: list[WebSocket] = []
|
_connections: list[WebSocket] = []
|
||||||
|
|
||||||
|
|
||||||
def _drop(websocket: WebSocket) -> None:
|
|
||||||
"""Remove a connection if still present — idempotent, never raises."""
|
|
||||||
with contextlib.suppress(ValueError):
|
|
||||||
_connections.remove(websocket)
|
|
||||||
|
|
||||||
|
|
||||||
@router.websocket("/ws/status")
|
@router.websocket("/ws/status")
|
||||||
async def ws_status(websocket: WebSocket) -> None:
|
async def ws_status(websocket: WebSocket) -> None:
|
||||||
# Accept first so we can send a close frame with a reason code
|
# Accept first so we can send a close frame with a reason code
|
||||||
@@ -40,11 +33,7 @@ async def ws_status(websocket: WebSocket) -> None:
|
|||||||
while True:
|
while True:
|
||||||
await websocket.receive_text()
|
await websocket.receive_text()
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
_connections.remove(websocket)
|
||||||
finally:
|
|
||||||
# Any error (disconnect or otherwise) must release the slot, else the
|
|
||||||
# dead socket lingers in the broadcast pool.
|
|
||||||
_drop(websocket)
|
|
||||||
|
|
||||||
|
|
||||||
async def _broadcast(payload: str) -> None:
|
async def _broadcast(payload: str) -> None:
|
||||||
@@ -52,7 +41,7 @@ async def _broadcast(payload: str) -> None:
|
|||||||
try:
|
try:
|
||||||
await conn.send_text(payload)
|
await conn.send_text(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
_drop(conn)
|
_connections.remove(conn)
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
|
||||||
@@ -65,15 +54,6 @@ async def broadcast_status(node_id: str, status: str, checked_at: str, response_
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
|
|
||||||
await _broadcast(json.dumps({
|
|
||||||
"type": "service_status",
|
|
||||||
"node_id": node_id,
|
|
||||||
"services": services,
|
|
||||||
"checked_at": checked_at,
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
async def broadcast_scan_update(run_id: str, devices_found: int) -> None:
|
||||||
await _broadcast(json.dumps({
|
await _broadcast(json.dumps({
|
||||||
"type": "scan_device_found",
|
"type": "scan_device_found",
|
||||||
|
|||||||
@@ -1,407 +0,0 @@
|
|||||||
"""FastAPI router for Zigbee2MQTT import."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
||||||
from sqlalchemy import delete as sa_delete
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
|
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
|
||||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
|
||||||
from app.schemas.scan import ScanRunResponse
|
|
||||||
from app.schemas.zigbee import (
|
|
||||||
ZigbeeConfig,
|
|
||||||
ZigbeeCoordinatorOut,
|
|
||||||
ZigbeeEdgeOut,
|
|
||||||
ZigbeeImportPendingResponse,
|
|
||||||
ZigbeeImportRequest,
|
|
||||||
ZigbeeImportResponse,
|
|
||||||
ZigbeeNodeOut,
|
|
||||||
ZigbeeSyncConfig,
|
|
||||||
ZigbeeTestConnectionRequest,
|
|
||||||
ZigbeeTestConnectionResponse,
|
|
||||||
)
|
|
||||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
|
||||||
from app.services.zigbee_service import (
|
|
||||||
build_zigbee_properties,
|
|
||||||
fetch_networkmap,
|
|
||||||
merge_zigbee_properties,
|
|
||||||
test_mqtt_connection,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import", response_model=ZigbeeImportResponse)
|
|
||||||
async def import_zigbee_network(
|
|
||||||
payload: ZigbeeImportRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZigbeeImportResponse:
|
|
||||||
"""Fetch the Zigbee2MQTT network map and return nodes + edges ready for canvas drop.
|
|
||||||
|
|
||||||
Connects to the specified MQTT broker, publishes a networkmap request to
|
|
||||||
``<base_topic>/bridge/request/networkmap``, and waits up to 60 s for the
|
|
||||||
response (large meshes can take 30 s+). The devices are returned as typed homelable nodes with a
|
|
||||||
coordinator → router → end-device hierarchy.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_networkmap(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
base_topic=payload.base_topic,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
except ImportError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
except ConnectionError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unexpected error during Zigbee import")
|
|
||||||
raise HTTPException(status_code=500, detail="Unexpected error during Zigbee import") from exc
|
|
||||||
|
|
||||||
nodes = [ZigbeeNodeOut(**n) for n in nodes_raw]
|
|
||||||
edges = [ZigbeeEdgeOut(**e) for e in edges_raw]
|
|
||||||
return ZigbeeImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
|
||||||
async def import_zigbee_to_pending(
|
|
||||||
payload: ZigbeeImportRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Queue a Zigbee2MQTT pending import as a background scan run.
|
|
||||||
|
|
||||||
Returns the ScanRun row immediately so the UI can close the import
|
|
||||||
modal and surface progress under Scan History (kind=zigbee). The
|
|
||||||
actual MQTT fetch + pending upsert happens in the background.
|
|
||||||
"""
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="zigbee",
|
|
||||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def env_import_request() -> ZigbeeImportRequest:
|
|
||||||
"""Build an import request from the server env config (for auto-sync).
|
|
||||||
|
|
||||||
MQTT credentials live in the env only — never in the request body or any
|
|
||||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
|
||||||
their connection settings here so there is a single source of truth."""
|
|
||||||
return ZigbeeImportRequest(
|
|
||||||
mqtt_host=settings.zigbee_mqtt_host,
|
|
||||||
mqtt_port=settings.zigbee_mqtt_port,
|
|
||||||
mqtt_username=settings.zigbee_mqtt_username or None,
|
|
||||||
mqtt_password=settings.zigbee_mqtt_password or None,
|
|
||||||
base_topic=settings.zigbee_base_topic,
|
|
||||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
|
||||||
mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
|
||||||
async def sync_zigbee_now(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Trigger an immediate Zigbee import using the server env config.
|
|
||||||
|
|
||||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
|
||||||
from ``settings`` (env) rather than the request body — the manual
|
|
||||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
|
||||||
"""
|
|
||||||
if not settings.zigbee_mqtt_host:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot sync: no Zigbee MQTT host configured on the server.",
|
|
||||||
)
|
|
||||||
payload = env_import_request()
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="zigbee",
|
|
||||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(_background_zigbee_import, run.id, payload)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_networkmap(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
base_topic=payload.base_topic,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "done"
|
|
||||||
run.devices_found = result.device_count
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Zigbee import %s failed", run_id)
|
|
||||||
await db.rollback()
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "error"
|
|
||||||
run.error = str(exc)[:500]
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _persist_pending_import(
|
|
||||||
db: AsyncSession,
|
|
||||||
nodes_raw: list[dict[str, Any]],
|
|
||||||
edges_raw: list[dict[str, Any]],
|
|
||||||
) -> ZigbeeImportPendingResponse:
|
|
||||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
|
||||||
|
|
||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
|
||||||
All zigbee-source links are wiped and re-inserted from the new map.
|
|
||||||
"""
|
|
||||||
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
|
|
||||||
# the by-IEEE lookups below resolve to a single row.
|
|
||||||
await dedupe_nodes_by_ieee(db)
|
|
||||||
|
|
||||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
|
||||||
# stay unset — retained for backward-compatible response shape.
|
|
||||||
coordinator_out: ZigbeeCoordinatorOut | None = None
|
|
||||||
coordinator_existed = False
|
|
||||||
pending_created = 0
|
|
||||||
pending_updated = 0
|
|
||||||
|
|
||||||
for n in nodes_raw:
|
|
||||||
ieee = n.get("ieee_address")
|
|
||||||
if not ieee:
|
|
||||||
continue
|
|
||||||
props = build_zigbee_properties(
|
|
||||||
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
|
||||||
)
|
|
||||||
|
|
||||||
# The coordinator is no longer auto-placed on the canvas — it flows to
|
|
||||||
# the pending inventory like every other device, so the user approves it
|
|
||||||
# explicitly. Only the shared paths below run for it.
|
|
||||||
|
|
||||||
# If the device has already been approved as a canvas Node, refresh its
|
|
||||||
# properties on every canvas it sits on. Still ensure the discovery
|
|
||||||
# inventory carries a row for it (status="approved") so it shows in the
|
|
||||||
# inventory list with an "In N canvas" badge — legacy auto-placed
|
|
||||||
# coordinators never got a pending row, which is why they went missing.
|
|
||||||
existing_nodes = (
|
|
||||||
await db.execute(
|
|
||||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
if existing_nodes:
|
|
||||||
for existing_node in existing_nodes:
|
|
||||||
existing_node.properties = merge_zigbee_properties(
|
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
inv = (
|
|
||||||
await db.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if inv is None:
|
|
||||||
db.add(
|
|
||||||
PendingDevice(
|
|
||||||
ieee_address=ieee,
|
|
||||||
friendly_name=n.get("friendly_name"),
|
|
||||||
hostname=n.get("friendly_name"),
|
|
||||||
suggested_type=n.get("type"),
|
|
||||||
device_subtype=n.get("device_type"),
|
|
||||||
model=n.get("model"),
|
|
||||||
vendor=n.get("vendor"),
|
|
||||||
lqi=n.get("lqi"),
|
|
||||||
status="approved",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pending_created += 1
|
|
||||||
else:
|
|
||||||
# Refresh metadata but never change the row's status (an approved
|
|
||||||
# device stays approved; a hidden one stays hidden).
|
|
||||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
|
||||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
|
||||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
|
||||||
inv.model = n.get("model") or inv.model
|
|
||||||
inv.vendor = n.get("vendor") or inv.vendor
|
|
||||||
if n.get("lqi") is not None:
|
|
||||||
inv.lqi = n.get("lqi")
|
|
||||||
pending_updated += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
|
||||||
)
|
|
||||||
pending = result.scalar_one_or_none()
|
|
||||||
if pending is None:
|
|
||||||
db.add(
|
|
||||||
PendingDevice(
|
|
||||||
ieee_address=ieee,
|
|
||||||
friendly_name=n.get("friendly_name"),
|
|
||||||
hostname=n.get("friendly_name"),
|
|
||||||
suggested_type=n.get("type"),
|
|
||||||
device_subtype=n.get("device_type"),
|
|
||||||
model=n.get("model"),
|
|
||||||
vendor=n.get("vendor"),
|
|
||||||
lqi=n.get("lqi"),
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pending_created += 1
|
|
||||||
else:
|
|
||||||
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
|
||||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
|
||||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
|
||||||
pending.model = n.get("model") or pending.model
|
|
||||||
pending.vendor = n.get("vendor") or pending.vendor
|
|
||||||
if n.get("lqi") is not None:
|
|
||||||
pending.lqi = n.get("lqi")
|
|
||||||
if pending.status == "approved":
|
|
||||||
# The device was approved earlier but its canvas Node no longer
|
|
||||||
# exists (no Node matched the IEEE above) — it was deleted. Revive
|
|
||||||
# the row to "pending" so it reappears in the Pending list on
|
|
||||||
# re-import instead of being silently swallowed. (Issue #167)
|
|
||||||
pending.status = "pending"
|
|
||||||
elif pending.status == "hidden":
|
|
||||||
# Re-imported a hidden device → leave it hidden, just refresh fields.
|
|
||||||
pass
|
|
||||||
pending_updated += 1
|
|
||||||
|
|
||||||
# Replace all zigbee-source links with the freshly discovered set.
|
|
||||||
await db.execute(
|
|
||||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zigbee")
|
|
||||||
)
|
|
||||||
|
|
||||||
links_recorded = 0
|
|
||||||
seen: set[tuple[str, str]] = set()
|
|
||||||
for e in edges_raw:
|
|
||||||
src = e.get("source")
|
|
||||||
tgt = e.get("target")
|
|
||||||
if not src or not tgt or (src, tgt) in seen:
|
|
||||||
continue
|
|
||||||
seen.add((src, tgt))
|
|
||||||
db.add(
|
|
||||||
PendingDeviceLink(
|
|
||||||
source_ieee=src,
|
|
||||||
target_ieee=tgt,
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
links_recorded += 1
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ZigbeeImportPendingResponse(
|
|
||||||
pending_created=pending_created,
|
|
||||||
pending_updated=pending_updated,
|
|
||||||
coordinator=coordinator_out,
|
|
||||||
coordinator_already_existed=coordinator_existed,
|
|
||||||
links_recorded=links_recorded,
|
|
||||||
device_count=len(nodes_raw),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-connection", response_model=ZigbeeTestConnectionResponse)
|
|
||||||
async def test_zigbee_connection(
|
|
||||||
payload: ZigbeeTestConnectionRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZigbeeTestConnectionResponse:
|
|
||||||
"""Quick MQTT ping to validate broker connection before importing."""
|
|
||||||
try:
|
|
||||||
await test_mqtt_connection(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
return ZigbeeTestConnectionResponse(connected=True, message="Connection successful")
|
|
||||||
except ImportError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
except (ConnectionError, TimeoutError) as exc:
|
|
||||||
return ZigbeeTestConnectionResponse(connected=False, message=str(exc))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Unexpected error during connection test")
|
|
||||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=ZigbeeConfig)
|
|
||||||
async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig:
|
|
||||||
"""Return non-secret Zigbee config. Never includes MQTT credentials — only
|
|
||||||
whether a host is configured on the server for auto-sync."""
|
|
||||||
return ZigbeeConfig(
|
|
||||||
mqtt_host=settings.zigbee_mqtt_host,
|
|
||||||
mqtt_port=settings.zigbee_mqtt_port,
|
|
||||||
base_topic=settings.zigbee_base_topic,
|
|
||||||
mqtt_tls=settings.zigbee_mqtt_tls,
|
|
||||||
sync_enabled=settings.zigbee_sync_enabled,
|
|
||||||
sync_interval=settings.zigbee_sync_interval,
|
|
||||||
host_configured=bool(settings.zigbee_mqtt_host),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ZigbeeConfig)
|
|
||||||
async def save_zigbee_config(
|
|
||||||
payload: ZigbeeSyncConfig,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZigbeeConfig:
|
|
||||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
|
||||||
|
|
||||||
This is the ONLY Zigbee config the app writes. Connection settings
|
|
||||||
(host/port/credentials/topic/tls) are env-only and are never accepted or
|
|
||||||
persisted here — enabling auto-sync requires the MQTT host already set in
|
|
||||||
the server env, since the scheduled job reads it from there.
|
|
||||||
"""
|
|
||||||
if payload.sync_enabled and not settings.zigbee_mqtt_host:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
settings.zigbee_sync_enabled = payload.sync_enabled
|
|
||||||
settings.zigbee_sync_interval = payload.sync_interval
|
|
||||||
settings.save_overrides()
|
|
||||||
set_zigbee_sync_enabled(payload.sync_enabled)
|
|
||||||
if payload.sync_enabled:
|
|
||||||
reschedule_zigbee_sync(payload.sync_interval)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return await get_zigbee_config()
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
"""FastAPI router for Z-Wave JS UI (zwavejs2mqtt) import."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
|
||||||
from sqlalchemy import delete as sa_delete
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
|
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
|
||||||
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
|
||||||
from app.schemas.scan import ScanRunResponse
|
|
||||||
from app.schemas.zwave import (
|
|
||||||
ZwaveConfig,
|
|
||||||
ZwaveCoordinatorOut,
|
|
||||||
ZwaveEdgeOut,
|
|
||||||
ZwaveImportPendingResponse,
|
|
||||||
ZwaveImportRequest,
|
|
||||||
ZwaveImportResponse,
|
|
||||||
ZwaveNodeOut,
|
|
||||||
ZwaveSyncConfig,
|
|
||||||
ZwaveTestConnectionRequest,
|
|
||||||
ZwaveTestConnectionResponse,
|
|
||||||
)
|
|
||||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
|
||||||
from app.services.zwave_service import (
|
|
||||||
build_zwave_properties,
|
|
||||||
fetch_zwave_network,
|
|
||||||
merge_zwave_properties,
|
|
||||||
test_zwave_connection,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import", response_model=ZwaveImportResponse)
|
|
||||||
async def import_zwave_network(
|
|
||||||
payload: ZwaveImportRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZwaveImportResponse:
|
|
||||||
"""Fetch the Z-Wave node list and return nodes + edges ready for canvas drop.
|
|
||||||
|
|
||||||
Connects to the broker, publishes a ``getNodes`` request to the Z-Wave JS UI
|
|
||||||
gateway, and waits for the response. Devices are returned as typed homelable
|
|
||||||
nodes with a coordinator → router → end-device hierarchy.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_zwave_network(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
prefix=payload.prefix,
|
|
||||||
gateway_name=payload.gateway_name,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
except ImportError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
except ConnectionError as exc:
|
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
|
||||||
except TimeoutError as exc:
|
|
||||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Unexpected error during Z-Wave import")
|
|
||||||
raise HTTPException(status_code=500, detail="Unexpected error during Z-Wave import") from exc
|
|
||||||
|
|
||||||
nodes = [ZwaveNodeOut(**n) for n in nodes_raw]
|
|
||||||
edges = [ZwaveEdgeOut(**e) for e in edges_raw]
|
|
||||||
return ZwaveImportResponse(nodes=nodes, edges=edges, device_count=len(nodes))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-pending", response_model=ScanRunResponse)
|
|
||||||
async def import_zwave_to_pending(
|
|
||||||
payload: ZwaveImportRequest,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Queue a Z-Wave pending import as a background scan run (kind=zwave)."""
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="zwave",
|
|
||||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(_background_zwave_import, run.id, payload)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
def env_import_request() -> ZwaveImportRequest:
|
|
||||||
"""Build an import request from the server env config (for auto-sync).
|
|
||||||
|
|
||||||
MQTT credentials live in the env only — never in the request body or any
|
|
||||||
API response. The scheduled auto-sync job and ``/sync-now`` both source
|
|
||||||
their connection settings here so there is a single source of truth."""
|
|
||||||
return ZwaveImportRequest(
|
|
||||||
mqtt_host=settings.zwave_mqtt_host,
|
|
||||||
mqtt_port=settings.zwave_mqtt_port,
|
|
||||||
mqtt_username=settings.zwave_mqtt_username or None,
|
|
||||||
mqtt_password=settings.zwave_mqtt_password or None,
|
|
||||||
prefix=settings.zwave_prefix,
|
|
||||||
gateway_name=settings.zwave_gateway_name,
|
|
||||||
mqtt_tls=settings.zwave_mqtt_tls,
|
|
||||||
mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sync-now", response_model=ScanRunResponse)
|
|
||||||
async def sync_zwave_now(
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ScanRun:
|
|
||||||
"""Trigger an immediate Z-Wave import using the server env config.
|
|
||||||
|
|
||||||
Same background flow as ``/import-pending`` but sources the MQTT connection
|
|
||||||
from ``settings`` (env) rather than the request body — the manual
|
|
||||||
counterpart to the scheduled auto-sync job. Requires the env host to be set.
|
|
||||||
"""
|
|
||||||
if not settings.zwave_mqtt_host:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot sync: no Z-Wave MQTT host configured on the server.",
|
|
||||||
)
|
|
||||||
payload = env_import_request()
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="zwave",
|
|
||||||
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
background_tasks.add_task(_background_zwave_import, run.id, payload)
|
|
||||||
return run
|
|
||||||
|
|
||||||
|
|
||||||
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
try:
|
|
||||||
nodes_raw, edges_raw = await fetch_zwave_network(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
prefix=payload.prefix,
|
|
||||||
gateway_name=payload.gateway_name,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
result = await _persist_pending_import(db, nodes_raw, edges_raw)
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "done"
|
|
||||||
run.devices_found = result.device_count
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Z-Wave import %s failed", run_id)
|
|
||||||
await db.rollback()
|
|
||||||
run = await db.get(ScanRun, run_id)
|
|
||||||
if run:
|
|
||||||
run.status = "error"
|
|
||||||
run.error = str(exc)[:500]
|
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def _persist_pending_import(
|
|
||||||
db: AsyncSession,
|
|
||||||
nodes_raw: list[dict[str, Any]],
|
|
||||||
edges_raw: list[dict[str, Any]],
|
|
||||||
) -> ZwaveImportPendingResponse:
|
|
||||||
"""Upsert nodes/edges into pending_devices + pending_device_links.
|
|
||||||
|
|
||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
|
|
||||||
identity. All zwave-source links are wiped and re-inserted from the new map.
|
|
||||||
"""
|
|
||||||
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
|
|
||||||
# the by-IEEE lookups below resolve cleanly.
|
|
||||||
await dedupe_nodes_by_ieee(db)
|
|
||||||
|
|
||||||
# Coordinator is no longer auto-placed, so the response's coordinator fields
|
|
||||||
# stay unset — retained for backward-compatible response shape.
|
|
||||||
coordinator_out: ZwaveCoordinatorOut | None = None
|
|
||||||
coordinator_existed = False
|
|
||||||
pending_created = 0
|
|
||||||
pending_updated = 0
|
|
||||||
|
|
||||||
for n in nodes_raw:
|
|
||||||
ieee = n.get("ieee_address")
|
|
||||||
if not ieee:
|
|
||||||
continue
|
|
||||||
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
|
||||||
|
|
||||||
# The coordinator is no longer auto-placed on the canvas — it flows to
|
|
||||||
# the pending inventory like every other device, so the user approves it
|
|
||||||
# explicitly. Only the shared paths below run for it.
|
|
||||||
|
|
||||||
# Already approved as a canvas Node → refresh props on every canvas it
|
|
||||||
# sits on. Still ensure the discovery inventory carries a row for it
|
|
||||||
# (status="approved") so it shows in the inventory list with an
|
|
||||||
# "In N canvas" badge — legacy auto-placed coordinators never got a
|
|
||||||
# pending row, which is why they went missing.
|
|
||||||
existing_nodes = (
|
|
||||||
await db.execute(
|
|
||||||
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
if existing_nodes:
|
|
||||||
for existing_node in existing_nodes:
|
|
||||||
existing_node.properties = merge_zwave_properties(
|
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
inv = (
|
|
||||||
await db.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if inv is None:
|
|
||||||
db.add(
|
|
||||||
PendingDevice(
|
|
||||||
ieee_address=ieee,
|
|
||||||
friendly_name=n.get("friendly_name"),
|
|
||||||
hostname=n.get("friendly_name"),
|
|
||||||
suggested_type=n.get("type"),
|
|
||||||
device_subtype=n.get("device_type"),
|
|
||||||
model=n.get("model"),
|
|
||||||
vendor=n.get("vendor"),
|
|
||||||
lqi=n.get("lqi"),
|
|
||||||
status="approved",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pending_created += 1
|
|
||||||
else:
|
|
||||||
# Refresh metadata but never change the row's status.
|
|
||||||
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
|
||||||
inv.suggested_type = n.get("type") or inv.suggested_type
|
|
||||||
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
|
||||||
inv.model = n.get("model") or inv.model
|
|
||||||
inv.vendor = n.get("vendor") or inv.vendor
|
|
||||||
if n.get("lqi") is not None:
|
|
||||||
inv.lqi = n.get("lqi")
|
|
||||||
pending_updated += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = await db.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
|
||||||
)
|
|
||||||
pending = result.scalar_one_or_none()
|
|
||||||
if pending is None:
|
|
||||||
db.add(
|
|
||||||
PendingDevice(
|
|
||||||
ieee_address=ieee,
|
|
||||||
friendly_name=n.get("friendly_name"),
|
|
||||||
hostname=n.get("friendly_name"),
|
|
||||||
suggested_type=n.get("type"),
|
|
||||||
device_subtype=n.get("device_type"),
|
|
||||||
model=n.get("model"),
|
|
||||||
vendor=n.get("vendor"),
|
|
||||||
lqi=n.get("lqi"),
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
pending_created += 1
|
|
||||||
else:
|
|
||||||
pending.friendly_name = n.get("friendly_name") or pending.friendly_name
|
|
||||||
pending.suggested_type = n.get("type") or pending.suggested_type
|
|
||||||
pending.device_subtype = n.get("device_type") or pending.device_subtype
|
|
||||||
pending.model = n.get("model") or pending.model
|
|
||||||
pending.vendor = n.get("vendor") or pending.vendor
|
|
||||||
if pending.status == "approved":
|
|
||||||
# Approved earlier but the canvas Node is gone (deleted) — revive
|
|
||||||
# to "pending" so it reappears in the list instead of vanishing.
|
|
||||||
pending.status = "pending"
|
|
||||||
elif pending.status == "hidden":
|
|
||||||
pass
|
|
||||||
pending_updated += 1
|
|
||||||
|
|
||||||
# Replace all zwave-source links with the freshly discovered set.
|
|
||||||
await db.execute(
|
|
||||||
sa_delete(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "zwave")
|
|
||||||
)
|
|
||||||
|
|
||||||
links_recorded = 0
|
|
||||||
seen: set[tuple[str, str]] = set()
|
|
||||||
for e in edges_raw:
|
|
||||||
src = e.get("source")
|
|
||||||
tgt = e.get("target")
|
|
||||||
if not src or not tgt or (src, tgt) in seen:
|
|
||||||
continue
|
|
||||||
seen.add((src, tgt))
|
|
||||||
db.add(
|
|
||||||
PendingDeviceLink(
|
|
||||||
source_ieee=src,
|
|
||||||
target_ieee=tgt,
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
links_recorded += 1
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
return ZwaveImportPendingResponse(
|
|
||||||
pending_created=pending_created,
|
|
||||||
pending_updated=pending_updated,
|
|
||||||
coordinator=coordinator_out,
|
|
||||||
coordinator_already_existed=coordinator_existed,
|
|
||||||
links_recorded=links_recorded,
|
|
||||||
device_count=len(nodes_raw),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-connection", response_model=ZwaveTestConnectionResponse)
|
|
||||||
async def test_connection_endpoint(
|
|
||||||
payload: ZwaveTestConnectionRequest,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZwaveTestConnectionResponse:
|
|
||||||
"""Quick MQTT ping to validate broker connection before importing."""
|
|
||||||
try:
|
|
||||||
await test_zwave_connection(
|
|
||||||
mqtt_host=payload.mqtt_host,
|
|
||||||
mqtt_port=payload.mqtt_port,
|
|
||||||
username=payload.mqtt_username,
|
|
||||||
password=payload.mqtt_password,
|
|
||||||
tls=payload.mqtt_tls,
|
|
||||||
tls_insecure=payload.mqtt_tls_insecure,
|
|
||||||
)
|
|
||||||
return ZwaveTestConnectionResponse(connected=True, message="Connection successful")
|
|
||||||
except ImportError as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
except (ConnectionError, TimeoutError) as exc:
|
|
||||||
return ZwaveTestConnectionResponse(connected=False, message=str(exc))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Unexpected error during connection test")
|
|
||||||
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=ZwaveConfig)
|
|
||||||
async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig:
|
|
||||||
"""Return non-secret Z-Wave config. Never includes MQTT credentials — only
|
|
||||||
whether a host is configured on the server for auto-sync."""
|
|
||||||
return ZwaveConfig(
|
|
||||||
mqtt_host=settings.zwave_mqtt_host,
|
|
||||||
mqtt_port=settings.zwave_mqtt_port,
|
|
||||||
prefix=settings.zwave_prefix,
|
|
||||||
gateway_name=settings.zwave_gateway_name,
|
|
||||||
mqtt_tls=settings.zwave_mqtt_tls,
|
|
||||||
sync_enabled=settings.zwave_sync_enabled,
|
|
||||||
sync_interval=settings.zwave_sync_interval,
|
|
||||||
host_configured=bool(settings.zwave_mqtt_host),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/config", response_model=ZwaveConfig)
|
|
||||||
async def save_zwave_config(
|
|
||||||
payload: ZwaveSyncConfig,
|
|
||||||
_: str = Depends(get_current_user),
|
|
||||||
) -> ZwaveConfig:
|
|
||||||
"""Persist the auto-sync activation (enabled + interval) and apply it live.
|
|
||||||
|
|
||||||
This is the ONLY Z-Wave config the app writes. Connection settings
|
|
||||||
(host/port/credentials/prefix/gateway/tls) are env-only and are never
|
|
||||||
accepted or persisted here — enabling auto-sync requires the MQTT host
|
|
||||||
already set in the server env, since the scheduled job reads it from there.
|
|
||||||
"""
|
|
||||||
if payload.sync_enabled and not settings.zwave_mqtt_host:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
settings.zwave_sync_enabled = payload.sync_enabled
|
|
||||||
settings.zwave_sync_interval = payload.sync_interval
|
|
||||||
settings.save_overrides()
|
|
||||||
set_zwave_sync_enabled(payload.sync_enabled)
|
|
||||||
if payload.sync_enabled:
|
|
||||||
reschedule_zwave_sync(payload.sync_interval)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
||||||
|
|
||||||
return await get_zwave_config()
|
|
||||||
@@ -1,33 +1,14 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import model_validator
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
def _read_version() -> str:
|
|
||||||
for candidate in [
|
|
||||||
Path(__file__).parent.parent.parent.parent / "VERSION", # repo root (dev)
|
|
||||||
Path("/app/VERSION"), # Docker image
|
|
||||||
]:
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate.read_text().strip()
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
APP_VERSION = _read_version()
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||||
|
|
||||||
secret_key: str # Required — set SECRET_KEY in .env
|
secret_key: str # Required — set SECRET_KEY in .env
|
||||||
sqlite_path: str = "./data/homelab.db"
|
sqlite_path: str = "./data/homelab.db"
|
||||||
# Uploaded media (floor plans, and future raw image uploads) live on disk,
|
|
||||||
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
|
|
||||||
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
|
|
||||||
upload_dir: str = ""
|
|
||||||
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
@@ -38,102 +19,20 @@ class Settings(BaseSettings):
|
|||||||
auth_username: str = "admin"
|
auth_username: str = "admin"
|
||||||
auth_password_hash: str = ""
|
auth_password_hash: str = ""
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def check_password_hash(self) -> "Settings":
|
|
||||||
h = self.auth_password_hash
|
|
||||||
if h and not h.startswith("$2"):
|
|
||||||
logger.error(
|
|
||||||
"AUTH_PASSWORD_HASH looks invalid (does not start with '$2b$'). "
|
|
||||||
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
|
|
||||||
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
|
|
||||||
)
|
|
||||||
return self
|
|
||||||
|
|
||||||
# Scanner
|
# Scanner
|
||||||
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
scanner_ranges: list[str] = ["192.168.1.0/24"]
|
||||||
|
|
||||||
# Phase-2 version-detection (-sV) host timeout, seconds. Bounds the version
|
|
||||||
# pass so a stalling TLS port (e.g. Proxmox 8006) can't hang it. Discovered
|
|
||||||
# ports survive a timeout regardless; raise this on slow/overlay networks.
|
|
||||||
scanner_version_host_timeout: int = 60
|
|
||||||
|
|
||||||
# Deep scan — persisted defaults (overridable per-scan from the scan dialog).
|
|
||||||
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
|
||||||
scanner_http_ranges: list[str] = []
|
|
||||||
scanner_http_probe_enabled: bool = False
|
|
||||||
scanner_http_verify_tls: bool = False
|
|
||||||
|
|
||||||
# Status checker
|
# Status checker
|
||||||
status_checker_interval: int = 60
|
status_checker_interval: int = 60
|
||||||
|
|
||||||
# Per-service status checker (independent of node checks). Off by default.
|
|
||||||
service_check_enabled: bool = False
|
|
||||||
service_check_interval: int = 300
|
|
||||||
|
|
||||||
# MCP service key — set MCP_SERVICE_KEY in .env
|
# MCP service key — set MCP_SERVICE_KEY in .env
|
||||||
# Used by the MCP server to authenticate against the backend without a user password.
|
# Used by the MCP server to authenticate against the backend without a user password.
|
||||||
# Leave empty to disable MCP service key auth.
|
# Leave empty to disable MCP service key auth.
|
||||||
mcp_service_key: str = ""
|
mcp_service_key: str = ""
|
||||||
|
|
||||||
# Live view — optional read-only public canvas endpoint.
|
|
||||||
# Set to a random secret string to enable /api/v1/liveview?key=<value>.
|
|
||||||
# Leave unset (or empty) to keep the feature disabled (default).
|
|
||||||
liveview_key: str | None = None
|
|
||||||
|
|
||||||
# Homepage widget — optional read-only stats endpoint for gethomepage.
|
|
||||||
# Set to a random secret to enable /api/v1/stats/summary (X-API-Key header).
|
|
||||||
# Leave empty to keep the feature disabled (default).
|
|
||||||
homepage_api_key: str = ""
|
|
||||||
|
|
||||||
# Proxmox VE import.
|
|
||||||
# Token = a real credential → env/.env ONLY, never persisted by the app to
|
|
||||||
# scan_config.json and never returned by the API. token_id is
|
|
||||||
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
|
|
||||||
proxmox_token_id: str = ""
|
|
||||||
proxmox_token_secret: str = ""
|
|
||||||
# Non-secret connection + auto-sync config (persisted via save_overrides).
|
|
||||||
proxmox_host: str = ""
|
|
||||||
proxmox_port: int = 8006
|
|
||||||
proxmox_verify_tls: bool = True
|
|
||||||
proxmox_sync_enabled: bool = False
|
|
||||||
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
||||||
|
|
||||||
# Zigbee2MQTT auto-sync import.
|
|
||||||
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
|
|
||||||
# scan_config.json and never returned by the API. Only the auto-sync
|
|
||||||
# activation (enabled + interval) is persisted; connection config is env-only.
|
|
||||||
zigbee_mqtt_host: str = ""
|
|
||||||
zigbee_mqtt_port: int = 1883
|
|
||||||
zigbee_mqtt_username: str = ""
|
|
||||||
zigbee_mqtt_password: str = ""
|
|
||||||
zigbee_base_topic: str = "zigbee2mqtt"
|
|
||||||
zigbee_mqtt_tls: bool = False
|
|
||||||
zigbee_mqtt_tls_insecure: bool = False
|
|
||||||
zigbee_sync_enabled: bool = False
|
|
||||||
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
||||||
|
|
||||||
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
|
|
||||||
zwave_mqtt_host: str = ""
|
|
||||||
zwave_mqtt_port: int = 1883
|
|
||||||
zwave_mqtt_username: str = ""
|
|
||||||
zwave_mqtt_password: str = ""
|
|
||||||
zwave_prefix: str = "zwave"
|
|
||||||
zwave_gateway_name: str = "zwavejs2mqtt"
|
|
||||||
zwave_mqtt_tls: bool = False
|
|
||||||
zwave_mqtt_tls_insecure: bool = False
|
|
||||||
zwave_sync_enabled: bool = False
|
|
||||||
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
|
|
||||||
|
|
||||||
def _override_path(self) -> Path:
|
def _override_path(self) -> Path:
|
||||||
return Path(self.sqlite_path).parent / "scan_config.json"
|
return Path(self.sqlite_path).parent / "scan_config.json"
|
||||||
|
|
||||||
def media_dir(self) -> Path:
|
|
||||||
"""On-disk folder for uploaded media. Sits on the same persistent
|
|
||||||
volume as the SQLite DB unless UPLOAD_DIR is set."""
|
|
||||||
if self.upload_dir:
|
|
||||||
return Path(self.upload_dir)
|
|
||||||
return Path(self.sqlite_path).parent / "uploads"
|
|
||||||
|
|
||||||
def load_overrides(self) -> None:
|
def load_overrides(self) -> None:
|
||||||
"""Load runtime scan config overrides written by the API."""
|
"""Load runtime scan config overrides written by the API."""
|
||||||
try:
|
try:
|
||||||
@@ -142,35 +41,6 @@ class Settings(BaseSettings):
|
|||||||
self.scanner_ranges = data["scanner_ranges"]
|
self.scanner_ranges = data["scanner_ranges"]
|
||||||
if "status_checker_interval" in data:
|
if "status_checker_interval" in data:
|
||||||
self.status_checker_interval = int(data["status_checker_interval"])
|
self.status_checker_interval = int(data["status_checker_interval"])
|
||||||
if "service_check_enabled" in data:
|
|
||||||
self.service_check_enabled = bool(data["service_check_enabled"])
|
|
||||||
if "service_check_interval" in data:
|
|
||||||
self.service_check_interval = int(data["service_check_interval"])
|
|
||||||
if "scanner_http_ranges" in data:
|
|
||||||
self.scanner_http_ranges = list(data["scanner_http_ranges"])
|
|
||||||
if "scanner_http_probe_enabled" in data:
|
|
||||||
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
|
||||||
if "scanner_http_verify_tls" in data:
|
|
||||||
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
|
||||||
# Proxmox auto-sync activation only. Connection config (host, port,
|
|
||||||
# token, verify_tls) is env-only by design — never read from or
|
|
||||||
# written to this file. Persisting host here previously created a
|
|
||||||
# dual source of truth that silently clobbered PROXMOX_HOST.
|
|
||||||
if "proxmox_sync_enabled" in data:
|
|
||||||
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
|
|
||||||
if "proxmox_sync_interval" in data:
|
|
||||||
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
|
|
||||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
|
||||||
# connection config (host, port, credentials, topic, tls) is env-only
|
|
||||||
# by design — never read from or written to this file.
|
|
||||||
if "zigbee_sync_enabled" in data:
|
|
||||||
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
|
|
||||||
if "zigbee_sync_interval" in data:
|
|
||||||
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
|
|
||||||
if "zwave_sync_enabled" in data:
|
|
||||||
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
|
|
||||||
if "zwave_sync_interval" in data:
|
|
||||||
self.zwave_sync_interval = int(data["zwave_sync_interval"])
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -180,22 +50,6 @@ class Settings(BaseSettings):
|
|||||||
self._override_path().write_text(json.dumps({
|
self._override_path().write_text(json.dumps({
|
||||||
"scanner_ranges": self.scanner_ranges,
|
"scanner_ranges": self.scanner_ranges,
|
||||||
"status_checker_interval": self.status_checker_interval,
|
"status_checker_interval": self.status_checker_interval,
|
||||||
"service_check_enabled": self.service_check_enabled,
|
|
||||||
"service_check_interval": self.service_check_interval,
|
|
||||||
"scanner_http_ranges": self.scanner_http_ranges,
|
|
||||||
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
|
||||||
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
|
||||||
# Proxmox: only the auto-sync activation is persisted. Connection
|
|
||||||
# config (host, port, token, verify_tls) is env-only and must never
|
|
||||||
# be written to disk — that is the single source of truth.
|
|
||||||
"proxmox_sync_enabled": self.proxmox_sync_enabled,
|
|
||||||
"proxmox_sync_interval": self.proxmox_sync_interval,
|
|
||||||
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
|
|
||||||
# connection config (host/port/credentials/topic/tls) is env-only.
|
|
||||||
"zigbee_sync_enabled": self.zigbee_sync_enabled,
|
|
||||||
"zigbee_sync_interval": self.zigbee_sync_interval,
|
|
||||||
"zwave_sync_enabled": self.zwave_sync_enabled,
|
|
||||||
"zwave_sync_interval": self.zwave_sync_interval,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-352
@@ -1,9 +1,6 @@
|
|||||||
"""APScheduler setup for background scan and status check jobs."""
|
"""APScheduler setup for background scan and status check jobs."""
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -11,381 +8,50 @@ from sqlalchemy import select
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.database import AsyncSessionLocal
|
from app.db.database import AsyncSessionLocal
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
from app.services.status_checker import check_node, check_services
|
from app.services.status_checker import check_node
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.schemas.zigbee import ZigbeeImportRequest
|
|
||||||
from app.schemas.zwave import ZwaveImportRequest
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
scheduler: AsyncIOScheduler = AsyncIOScheduler()
|
||||||
|
|
||||||
|
|
||||||
async def _check_single_node(
|
async def _run_status_checks() -> None:
|
||||||
node_id: str,
|
"""Check all nodes and broadcast results via WebSocket."""
|
||||||
check_method: str,
|
|
||||||
check_target: str | None,
|
|
||||||
ip: str | None,
|
|
||||||
) -> tuple[str, dict[str, object] | None]:
|
|
||||||
"""Run a single node check; returns (node_id, result_or_None).
|
|
||||||
|
|
||||||
Accepts plain scalars — not an ORM object — so there is no risk of
|
|
||||||
DetachedInstanceError when the originating session has already closed.
|
|
||||||
"""
|
|
||||||
from app.api.routes.status import broadcast_status # avoid circular import
|
from app.api.routes.status import broadcast_status # avoid circular import
|
||||||
|
|
||||||
try:
|
|
||||||
check_result = await check_node(check_method, check_target, ip)
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
n = await db.get(Node, node_id)
|
result = await db.execute(select(Node))
|
||||||
|
nodes = result.scalars().all()
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
if not node.check_method:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
check_result = await check_node(node.check_method, node.check_target, node.ip)
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
n = await db.get(Node, node.id)
|
||||||
if n:
|
if n:
|
||||||
n.status = check_result["status"]
|
n.status = check_result["status"]
|
||||||
n.response_time_ms = check_result["response_time_ms"]
|
n.response_time_ms = check_result["response_time_ms"]
|
||||||
if check_result["status"] == "online":
|
n.last_seen = datetime.now(timezone.utc) if check_result["status"] == "online" else n.last_seen
|
||||||
n.last_seen = now
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await broadcast_status(
|
await broadcast_status(
|
||||||
node_id=node_id,
|
node_id=node.id,
|
||||||
status=check_result["status"],
|
status=check_result["status"],
|
||||||
checked_at=now.isoformat(),
|
checked_at=datetime.now(timezone.utc).isoformat(),
|
||||||
response_time_ms=check_result["response_time_ms"],
|
response_time_ms=check_result["response_time_ms"],
|
||||||
)
|
)
|
||||||
return node_id, check_result
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Status check failed for node %s: %s", node_id, exc)
|
logger.error("Status check failed for node %s: %s", node.id, exc)
|
||||||
return node_id, None
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_status_checks() -> None:
|
|
||||||
"""Check all nodes concurrently and broadcast results via WebSocket."""
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
result = await db.execute(select(Node))
|
|
||||||
nodes = result.scalars().all()
|
|
||||||
# Extract scalars while the session is open to avoid DetachedInstanceError
|
|
||||||
checkable = [
|
|
||||||
(n.id, n.check_method, n.check_target, n.ip)
|
|
||||||
for n in nodes
|
|
||||||
if n.check_method
|
|
||||||
]
|
|
||||||
|
|
||||||
if not checkable:
|
|
||||||
return
|
|
||||||
|
|
||||||
await asyncio.gather(*[
|
|
||||||
_check_single_node(node_id, method, target, ip)
|
|
||||||
for node_id, method, target, ip in checkable
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
def _node_host(ip: str | None, hostname: str | None) -> str | None:
|
|
||||||
"""Pick the address to probe services on: first IP, else hostname."""
|
|
||||||
if ip:
|
|
||||||
first = ip.split(",")[0].strip()
|
|
||||||
if first:
|
|
||||||
return first
|
|
||||||
return hostname or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_service_checks() -> None:
|
|
||||||
"""Check every service of every node and broadcast per-service results."""
|
|
||||||
if not settings.service_check_enabled:
|
|
||||||
return
|
|
||||||
from app.api.routes.status import broadcast_service_status # avoid circular import
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
result = await db.execute(select(Node))
|
|
||||||
nodes = result.scalars().all()
|
|
||||||
checkable = [
|
|
||||||
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
|
|
||||||
for n in nodes
|
|
||||||
if n.services
|
|
||||||
]
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
for node_id, host, services in checkable:
|
|
||||||
try:
|
|
||||||
statuses = await check_services(host, services)
|
|
||||||
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Service checks failed for node %s: %s", node_id, exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_proxmox_sync() -> None:
|
|
||||||
"""Fetch the Proxmox inventory and upsert it into pending (auto-sync).
|
|
||||||
|
|
||||||
Records a ScanRun (kind=proxmox) so the scheduled sync shows in Scan
|
|
||||||
history, exactly like the manual /sync-now and /import-pending paths.
|
|
||||||
"""
|
|
||||||
if not settings.proxmox_sync_enabled:
|
|
||||||
return
|
|
||||||
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
|
|
||||||
logger.warning("Proxmox auto-sync enabled but host/token not configured — skipping")
|
|
||||||
return
|
|
||||||
# Lazy import to avoid a circular import at module load.
|
|
||||||
from app.api.routes.proxmox import _background_proxmox_import
|
|
||||||
from app.db.models import ScanRun
|
|
||||||
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
run = ScanRun(
|
|
||||||
status="running",
|
|
||||||
kind="proxmox",
|
|
||||||
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
|
|
||||||
)
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
run_id = run.id
|
|
||||||
|
|
||||||
# Shares the manual-sync flow: fetch + persist + mark the run done/error +
|
|
||||||
# broadcast the inventory-reload signal.
|
|
||||||
await _background_proxmox_import(
|
|
||||||
run_id,
|
|
||||||
settings.proxmox_host,
|
|
||||||
settings.proxmox_port,
|
|
||||||
settings.proxmox_token_id,
|
|
||||||
settings.proxmox_token_secret,
|
|
||||||
settings.proxmox_verify_tls,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_mesh_sync(kind: str) -> None:
|
|
||||||
"""Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave).
|
|
||||||
|
|
||||||
Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan
|
|
||||||
history, then delegates to the exact same background import the manual
|
|
||||||
/sync-now and /import-pending paths use. Connection config comes from the
|
|
||||||
server env only (credentials never leave it).
|
|
||||||
"""
|
|
||||||
from app.db.models import ScanRun
|
|
||||||
|
|
||||||
payload: ZigbeeImportRequest | ZwaveImportRequest
|
|
||||||
background: Callable[[str, Any], Awaitable[None]]
|
|
||||||
|
|
||||||
if kind == "zigbee":
|
|
||||||
if not settings.zigbee_sync_enabled:
|
|
||||||
return
|
|
||||||
if not settings.zigbee_mqtt_host:
|
|
||||||
logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping")
|
|
||||||
return
|
|
||||||
from app.api.routes.zigbee import _background_zigbee_import
|
|
||||||
from app.api.routes.zigbee import env_import_request as _zigbee_env_request
|
|
||||||
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
|
|
||||||
payload = _zigbee_env_request()
|
|
||||||
background = _background_zigbee_import
|
|
||||||
else:
|
|
||||||
if not settings.zwave_sync_enabled:
|
|
||||||
return
|
|
||||||
if not settings.zwave_mqtt_host:
|
|
||||||
logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping")
|
|
||||||
return
|
|
||||||
from app.api.routes.zwave import _background_zwave_import
|
|
||||||
from app.api.routes.zwave import env_import_request as _zwave_env_request
|
|
||||||
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
|
|
||||||
payload = _zwave_env_request()
|
|
||||||
background = _background_zwave_import
|
|
||||||
async with AsyncSessionLocal() as db:
|
|
||||||
run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"])
|
|
||||||
db.add(run)
|
|
||||||
await db.commit()
|
|
||||||
await db.refresh(run)
|
|
||||||
run_id = run.id
|
|
||||||
|
|
||||||
await background(run_id, payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_zigbee_sync() -> None:
|
|
||||||
await _run_mesh_sync("zigbee")
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_zwave_sync() -> None:
|
|
||||||
await _run_mesh_sync("zwave")
|
|
||||||
|
|
||||||
|
|
||||||
def _add_service_check_job() -> None:
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_service_checks,
|
|
||||||
"interval",
|
|
||||||
seconds=settings.service_check_interval,
|
|
||||||
id="service_checks",
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_proxmox_sync_job() -> None:
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_proxmox_sync,
|
|
||||||
"interval",
|
|
||||||
seconds=settings.proxmox_sync_interval,
|
|
||||||
id="proxmox_sync",
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_zigbee_sync_job() -> None:
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_zigbee_sync,
|
|
||||||
"interval",
|
|
||||||
seconds=settings.zigbee_sync_interval,
|
|
||||||
id="zigbee_sync",
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _add_zwave_sync_job() -> None:
|
|
||||||
scheduler.add_job(
|
|
||||||
_run_zwave_sync,
|
|
||||||
"interval",
|
|
||||||
seconds=settings.zwave_sync_interval,
|
|
||||||
id="zwave_sync",
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def start_scheduler() -> None:
|
def start_scheduler() -> None:
|
||||||
global scheduler
|
global scheduler
|
||||||
if scheduler.running:
|
|
||||||
try:
|
|
||||||
scheduler.shutdown(wait=False)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("Failed to shut down previous scheduler instance: %s", exc)
|
|
||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
scheduler.add_job(
|
scheduler.add_job(_run_status_checks, "interval", seconds=settings.status_checker_interval, id="status_checks")
|
||||||
_run_status_checks,
|
|
||||||
"interval",
|
|
||||||
seconds=settings.status_checker_interval,
|
|
||||||
id="status_checks",
|
|
||||||
max_instances=1,
|
|
||||||
coalesce=True,
|
|
||||||
)
|
|
||||||
if settings.service_check_enabled:
|
|
||||||
_add_service_check_job()
|
|
||||||
if settings.proxmox_sync_enabled:
|
|
||||||
_add_proxmox_sync_job()
|
|
||||||
if settings.zigbee_sync_enabled:
|
|
||||||
_add_zigbee_sync_job()
|
|
||||||
if settings.zwave_sync_enabled:
|
|
||||||
_add_zwave_sync_job()
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
|
||||||
|
|
||||||
|
|
||||||
def reschedule_status_checks(interval_seconds: int) -> None:
|
|
||||||
"""Update the status check interval on the running scheduler."""
|
|
||||||
if interval_seconds < 10:
|
|
||||||
raise ValueError(f"interval_seconds must be >= 10, got {interval_seconds}")
|
|
||||||
if not scheduler.running:
|
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
|
||||||
return
|
|
||||||
scheduler.reschedule_job("status_checks", trigger="interval", seconds=interval_seconds)
|
|
||||||
logger.info("Status checks rescheduled to every %ds", interval_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def reschedule_service_checks(interval_seconds: int) -> None:
|
|
||||||
"""Update the service-check interval on the running scheduler (if enabled)."""
|
|
||||||
if interval_seconds < 30:
|
|
||||||
raise ValueError(f"interval_seconds must be >= 30, got {interval_seconds}")
|
|
||||||
if not scheduler.running:
|
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
|
||||||
return
|
|
||||||
if scheduler.get_job("service_checks"):
|
|
||||||
scheduler.reschedule_job("service_checks", trigger="interval", seconds=interval_seconds)
|
|
||||||
logger.info("Service checks rescheduled to every %ds", interval_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def set_service_checks_enabled(enabled: bool) -> None:
|
|
||||||
"""Add or remove the service-check job on the running scheduler."""
|
|
||||||
if not scheduler.running:
|
|
||||||
return
|
|
||||||
job = scheduler.get_job("service_checks")
|
|
||||||
if enabled and not job:
|
|
||||||
_add_service_check_job()
|
|
||||||
logger.info("Service checks enabled — every %ds", settings.service_check_interval)
|
|
||||||
elif not enabled and job:
|
|
||||||
scheduler.remove_job("service_checks")
|
|
||||||
logger.info("Service checks disabled")
|
|
||||||
|
|
||||||
|
|
||||||
def reschedule_proxmox_sync(interval_seconds: int) -> None:
|
|
||||||
"""Update the Proxmox auto-sync interval on the running scheduler (if enabled)."""
|
|
||||||
if interval_seconds < 300:
|
|
||||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
|
||||||
if not scheduler.running:
|
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
|
||||||
return
|
|
||||||
if scheduler.get_job("proxmox_sync"):
|
|
||||||
scheduler.reschedule_job("proxmox_sync", trigger="interval", seconds=interval_seconds)
|
|
||||||
logger.info("Proxmox auto-sync rescheduled to every %ds", interval_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def set_proxmox_sync_enabled(enabled: bool) -> None:
|
|
||||||
"""Add or remove the Proxmox auto-sync job on the running scheduler."""
|
|
||||||
if not scheduler.running:
|
|
||||||
return
|
|
||||||
job = scheduler.get_job("proxmox_sync")
|
|
||||||
if enabled and not job:
|
|
||||||
_add_proxmox_sync_job()
|
|
||||||
logger.info("Proxmox auto-sync enabled — every %ds", settings.proxmox_sync_interval)
|
|
||||||
elif not enabled and job:
|
|
||||||
scheduler.remove_job("proxmox_sync")
|
|
||||||
logger.info("Proxmox auto-sync disabled")
|
|
||||||
|
|
||||||
|
|
||||||
def reschedule_zigbee_sync(interval_seconds: int) -> None:
|
|
||||||
"""Update the Zigbee auto-sync interval on the running scheduler (if enabled)."""
|
|
||||||
if interval_seconds < 300:
|
|
||||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
|
||||||
if not scheduler.running:
|
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
|
||||||
return
|
|
||||||
if scheduler.get_job("zigbee_sync"):
|
|
||||||
scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds)
|
|
||||||
logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def set_zigbee_sync_enabled(enabled: bool) -> None:
|
|
||||||
"""Add or remove the Zigbee auto-sync job on the running scheduler."""
|
|
||||||
if not scheduler.running:
|
|
||||||
return
|
|
||||||
job = scheduler.get_job("zigbee_sync")
|
|
||||||
if enabled and not job:
|
|
||||||
_add_zigbee_sync_job()
|
|
||||||
logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval)
|
|
||||||
elif not enabled and job:
|
|
||||||
scheduler.remove_job("zigbee_sync")
|
|
||||||
logger.info("Zigbee auto-sync disabled")
|
|
||||||
|
|
||||||
|
|
||||||
def reschedule_zwave_sync(interval_seconds: int) -> None:
|
|
||||||
"""Update the Z-Wave auto-sync interval on the running scheduler (if enabled)."""
|
|
||||||
if interval_seconds < 300:
|
|
||||||
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
|
|
||||||
if not scheduler.running:
|
|
||||||
logger.warning("Scheduler not running, skipping reschedule")
|
|
||||||
return
|
|
||||||
if scheduler.get_job("zwave_sync"):
|
|
||||||
scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds)
|
|
||||||
logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
def set_zwave_sync_enabled(enabled: bool) -> None:
|
|
||||||
"""Add or remove the Z-Wave auto-sync job on the running scheduler."""
|
|
||||||
if not scheduler.running:
|
|
||||||
return
|
|
||||||
job = scheduler.get_job("zwave_sync")
|
|
||||||
if enabled and not job:
|
|
||||||
_add_zwave_sync_job()
|
|
||||||
logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval)
|
|
||||||
elif not enabled and job:
|
|
||||||
scheduler.remove_job("zwave_sync")
|
|
||||||
logger.info("Z-Wave auto-sync disabled")
|
|
||||||
|
|
||||||
|
|
||||||
def stop_scheduler() -> None:
|
def stop_scheduler() -> None:
|
||||||
if scheduler.running:
|
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
if not plain or not hashed:
|
return bool(pwd_context.verify(plain, hashed))
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def hash_password(password: str) -> str:
|
def hash_password(password: str) -> str:
|
||||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
return str(pwd_context.hash(password))
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str) -> str:
|
def create_access_token(subject: str) -> str:
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"vendor": "Proxmox / QEMU / KVM",
|
|
||||||
"type": "vm",
|
|
||||||
"prefixes": ["52:54:00", "bc:24:11"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "VMware",
|
|
||||||
"type": "vm",
|
|
||||||
"prefixes": ["00:50:56", "00:0c:29", "00:05:69", "00:1c:14"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "VirtualBox",
|
|
||||||
"type": "vm",
|
|
||||||
"prefixes": ["08:00:27"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Microsoft Hyper-V",
|
|
||||||
"type": "vm",
|
|
||||||
"prefixes": ["00:15:5d"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Xen",
|
|
||||||
"type": "vm",
|
|
||||||
"prefixes": ["00:16:3e"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "MikroTik",
|
|
||||||
"type": "router",
|
|
||||||
"prefixes": [
|
|
||||||
"00:0c:42",
|
|
||||||
"08:55:31",
|
|
||||||
"18:fd:74",
|
|
||||||
"2c:c8:1b",
|
|
||||||
"48:8f:5a",
|
|
||||||
"4c:5e:0c",
|
|
||||||
"64:d1:54",
|
|
||||||
"6c:3b:6b",
|
|
||||||
"74:4d:28",
|
|
||||||
"b8:69:f4",
|
|
||||||
"c4:ad:34",
|
|
||||||
"cc:2d:e0",
|
|
||||||
"d4:ca:6d",
|
|
||||||
"dc:2c:6e",
|
|
||||||
"e4:8d:8c"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Ubiquiti",
|
|
||||||
"type": "ap",
|
|
||||||
"prefixes": [
|
|
||||||
"00:15:6d",
|
|
||||||
"00:27:22",
|
|
||||||
"04:18:d6",
|
|
||||||
"24:5a:4c",
|
|
||||||
"24:a4:3c",
|
|
||||||
"44:d9:e7",
|
|
||||||
"68:72:51",
|
|
||||||
"68:d7:9a",
|
|
||||||
"74:83:c2",
|
|
||||||
"78:8a:20",
|
|
||||||
"78:45:58",
|
|
||||||
"80:2a:a8",
|
|
||||||
"94:2a:6f",
|
|
||||||
"9c:05:d6",
|
|
||||||
"b4:fb:e4",
|
|
||||||
"dc:9f:db",
|
|
||||||
"e0:63:da",
|
|
||||||
"f0:9f:c2",
|
|
||||||
"fc:ec:da"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Ruckus Wireless",
|
|
||||||
"type": "ap",
|
|
||||||
"prefixes": ["00:13:92", "4c:b1:cd", "8c:7a:15", "f0:b0:52", "c0:8a:de"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Aruba Networks (HPE)",
|
|
||||||
"type": "ap",
|
|
||||||
"prefixes": ["00:0b:86", "6c:f3:7f", "94:b4:0f", "9c:1c:12", "ac:a3:1e"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Cisco Systems",
|
|
||||||
"type": "switch",
|
|
||||||
"prefixes": [
|
|
||||||
"00:00:0c",
|
|
||||||
"00:1b:0d",
|
|
||||||
"00:1c:f6",
|
|
||||||
"00:1e:13",
|
|
||||||
"00:23:04",
|
|
||||||
"00:24:13",
|
|
||||||
"00:25:45",
|
|
||||||
"00:50:0b",
|
|
||||||
"b0:00:b4",
|
|
||||||
"b8:38:61",
|
|
||||||
"f8:c0:01"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Juniper Networks",
|
|
||||||
"type": "switch",
|
|
||||||
"prefixes": ["00:14:f6", "2c:6b:f5", "b0:c6:9a", "f0:1c:2d"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Zyxel",
|
|
||||||
"type": "switch",
|
|
||||||
"prefixes": ["00:13:49", "60:31:97", "ec:43:f6"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Netgear",
|
|
||||||
"type": "router",
|
|
||||||
"prefixes": ["00:09:5b", "28:c6:8e", "c0:ff:d4", "2c:30:33", "a0:40:a0"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "TP-Link",
|
|
||||||
"type": "router",
|
|
||||||
"prefixes": ["14:eb:b6", "60:e3:27", "b0:4e:26", "c4:e9:0a", "ec:08:6b"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Synology",
|
|
||||||
"type": "nas",
|
|
||||||
"prefixes": ["00:11:32", "00:f4:6f", "90:09:d0"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "QNAP Systems",
|
|
||||||
"type": "nas",
|
|
||||||
"prefixes": ["00:08:9b", "00:0e:23", "00:13:42", "04:f0:21", "24:5e:be"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Asustor",
|
|
||||||
"type": "nas",
|
|
||||||
"prefixes": ["e8:9c:25"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Hikvision",
|
|
||||||
"type": "camera",
|
|
||||||
"prefixes": ["28:57:be", "44:19:b6", "b4:a3:82", "bc:ad:28", "c0:51:7e", "c0:56:e3", "c4:2f:90"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Dahua / Amcrest",
|
|
||||||
"type": "camera",
|
|
||||||
"prefixes": ["3c:ef:8c", "4c:11:bf", "90:02:a9", "bc:32:5f", "e0:50:8b"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Reolink",
|
|
||||||
"type": "camera",
|
|
||||||
"prefixes": ["ec:71:db"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Axis Communications",
|
|
||||||
"type": "camera",
|
|
||||||
"prefixes": ["00:40:8c", "ac:cc:8e"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Raspberry Pi Foundation",
|
|
||||||
"type": "server",
|
|
||||||
"prefixes": ["28:cd:c1", "2c:cf:67", "b8:27:eb", "d8:3a:dd", "dc:a6:32", "e4:5f:01"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Dell",
|
|
||||||
"type": "server",
|
|
||||||
"prefixes": ["00:14:22", "90:b1:1c", "b0:83:fe", "b8:ca:3a", "f8:b1:56"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Supermicro",
|
|
||||||
"type": "server",
|
|
||||||
"prefixes": ["00:25:90", "0c:c4:7a", "ac:1f:6b"]
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
"vendor": "Shelly",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["30:c6:f7", "34:94:54", "84:f3:eb", "ec:fa:bc"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Espressif (ESP8266 / ESP32)",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": [
|
|
||||||
"24:62:ab",
|
|
||||||
"30:ae:a4",
|
|
||||||
"3c:71:bf",
|
|
||||||
"8c:aa:b5",
|
|
||||||
"a0:20:a6",
|
|
||||||
"ac:67:b2",
|
|
||||||
"b4:e6:2d",
|
|
||||||
"cc:50:e3"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Sonoff / ITEAD",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["dc:4f:22", "e8:db:84"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "TP-Link Tapo / Kasa",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["10:27:f5", "1c:3b:f3", "50:c7:bf", "b0:a7:b9"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Philips Hue",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["00:17:88", "ec:b5:fa"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "IKEA Tradfri",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["00:21:2e", "34:13:e8"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vendor": "Tuya / Smart Life",
|
|
||||||
"type": "iot",
|
|
||||||
"prefixes": ["68:57:2d", "d8:f1:5b"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -142,69 +142,5 @@
|
|||||||
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||||
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"},
|
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Plex", "service_name": "Plex", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Emby", "service_name": "Emby", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Overseerr", "service_name": "Overseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jellyseerr", "service_name": "Jellyseerr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Tautulli", "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Navidrome", "service_name": "Navidrome", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]udiobookshelf", "service_name": "Audiobookshelf", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Immich", "service_name": "Immich", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "PhotoPrism", "service_name": "PhotoPrism", "icon": "camera", "category": "media", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Calibre[- ]Web", "service_name": "Calibre-Web", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Sonarr", "service_name": "Sonarr", "icon": "tv", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Radarr", "service_name": "Radarr", "icon": "film", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Lidarr", "service_name": "Lidarr", "icon": "music", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Readarr", "service_name": "Readarr", "icon": "book", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Prowlarr", "service_name": "Prowlarr", "icon": "search", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Bazarr", "service_name": "Bazarr", "icon": "subtitles", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "qBittorrent", "service_name": "qBittorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "SABnzbd", "service_name": "SABnzbd", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homarr", "service_name": "Homarr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Heimdall", "service_name": "Heimdall", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dashy", "service_name": "Dashy", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Organizr", "service_name": "Organizr", "icon": "home", "category": "web", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Portainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dockge", "service_name": "Dockge", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Yacht", "service_name": "Yacht", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Home Assistant", "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Node-RED", "service_name": "Node-RED", "icon": "share-2", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Zigbee2MQTT", "service_name": "Zigbee2MQTT", "icon": "radio", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "ESPHome", "service_name": "ESPHome", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openHAB", "service_name": "openHAB", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Homebridge", "service_name": "Homebridge", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Jeedom", "service_name": "Jeedom", "icon": "home", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Scrypted", "service_name": "Scrypted", "icon": "video", "category": "automation", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Grafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Uptime Kuma", "service_name": "Uptime Kuma", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Netdata", "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Glances", "service_name": "Glances", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Dozzle", "service_name": "Dozzle", "icon": "terminal", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "AdGuard Home", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Pi-hole", "service_name": "Pi-hole", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nginx Proxy Manager", "service_name": "Nginx Proxy Manager", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Traefik", "service_name": "Traefik", "icon": "share-2", "category": "network", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Vaultwarden|Bitwarden", "service_name": "Vaultwarden", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Authelia", "service_name": "Authelia", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "lock", "category": "auth", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Nextcloud", "service_name": "Nextcloud", "icon": "hard-drive", "category": "storage", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Paperless", "service_name": "Paperless-ngx", "icon": "book", "category": "storage", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
|
||||||
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
|
||||||
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
|
|
||||||
]
|
]
|
||||||
|
|||||||
+17
-313
@@ -1,35 +1,11 @@
|
|||||||
import json as _json
|
|
||||||
import logging
|
|
||||||
import shutil
|
|
||||||
import uuid as _uuid_mod
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlalchemy.exc import OperationalError
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from app.core.config import APP_VERSION, settings
|
from app.core.config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
async def _try_migrate(conn: AsyncConnection, sql: str, *, label: str) -> None:
|
|
||||||
"""Run an idempotent migration statement, logging any error.
|
|
||||||
|
|
||||||
Distinguishes 'already applied' errors (debug) from genuine failures
|
|
||||||
(warning) so silent corruption is avoided. Used for new in-commit
|
|
||||||
migrations; existing legacy ALTERs above remain wrapped in suppress.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
await conn.exec_driver_sql(sql)
|
|
||||||
except OperationalError as exc:
|
|
||||||
msg = str(exc).lower()
|
|
||||||
if "duplicate column" in msg or "already exists" in msg:
|
|
||||||
logger.debug("Migration %s skipped (already applied): %s", label, exc)
|
|
||||||
else:
|
|
||||||
logger.warning("Migration %s failed: %s", label, exc)
|
|
||||||
|
|
||||||
# Ensure the data directory exists before SQLite tries to open the file
|
# Ensure the data directory exists before SQLite tries to open the file
|
||||||
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -46,312 +22,40 @@ class Base(DeclarativeBase):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _backup_db() -> None:
|
|
||||||
db_path = Path(settings.sqlite_path)
|
|
||||||
if not db_path.exists():
|
|
||||||
return
|
|
||||||
backup_path = db_path.with_suffix(f".db.back-{APP_VERSION}")
|
|
||||||
if backup_path.exists():
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
shutil.copy2(db_path, backup_path)
|
|
||||||
logger.info("DB backup created: %s", backup_path.name)
|
|
||||||
except OSError:
|
|
||||||
logger.warning("Could not create DB backup at %s", backup_path)
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
_backup_db()
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
# Add columns introduced after initial schema (idempotent)
|
# Add columns introduced after initial schema (idempotent)
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN container_mode BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_colors JSON")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN custom_color TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN path_style TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN custom_icon TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN source_handle TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start TEXT NOT NULL DEFAULT 'none'")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN line_style TEXT")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN width_mult REAL")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_model TEXT")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN ram_gb REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN disk_gb REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_hardware BOOLEAN NOT NULL DEFAULT 0")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN show_port_numbers BOOLEAN NOT NULL DEFAULT 0")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN width REAL")
|
||||||
with suppress(OperationalError):
|
with suppress(Exception):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
|
|
||||||
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
|
|
||||||
zigbee_migrations: list[tuple[str, str]] = [
|
|
||||||
("nodes.ieee_address", "ALTER TABLE nodes ADD COLUMN ieee_address TEXT"),
|
|
||||||
(
|
|
||||||
"nodes.ieee_address.index",
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_nodes_ieee_address ON nodes(ieee_address)",
|
|
||||||
),
|
|
||||||
("pending_devices.ieee_address", "ALTER TABLE pending_devices ADD COLUMN ieee_address TEXT"),
|
|
||||||
(
|
|
||||||
"pending_devices.ieee_address.index",
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
|
||||||
"ON pending_devices(ieee_address)",
|
|
||||||
),
|
|
||||||
("pending_devices.friendly_name", "ALTER TABLE pending_devices ADD COLUMN friendly_name TEXT"),
|
|
||||||
("pending_devices.device_subtype", "ALTER TABLE pending_devices ADD COLUMN device_subtype TEXT"),
|
|
||||||
("pending_devices.model", "ALTER TABLE pending_devices ADD COLUMN model TEXT"),
|
|
||||||
("pending_devices.vendor", "ALTER TABLE pending_devices ADD COLUMN vendor TEXT"),
|
|
||||||
("pending_devices.lqi", "ALTER TABLE pending_devices ADD COLUMN lqi INTEGER"),
|
|
||||||
]
|
|
||||||
for label, sql in zigbee_migrations:
|
|
||||||
await _try_migrate(conn, sql, label=label)
|
|
||||||
# Drop NOT NULL on pending_devices.ip (Zigbee devices have no IP).
|
|
||||||
# SQLite can't ALTER column nullability — rebuild the table if needed.
|
|
||||||
try:
|
|
||||||
info = await conn.exec_driver_sql("PRAGMA table_info(pending_devices)")
|
|
||||||
cols = info.fetchall()
|
|
||||||
ip_col = next((c for c in cols if c[1] == "ip"), None)
|
|
||||||
# PRAGMA table_info row layout: (cid, name, type, notnull, dflt, pk)
|
|
||||||
if ip_col and ip_col[3] == 1:
|
|
||||||
logger.info("Migrating pending_devices: dropping NOT NULL on ip column")
|
|
||||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE TABLE pending_devices_new ("
|
|
||||||
"id VARCHAR PRIMARY KEY,"
|
|
||||||
"ip VARCHAR,"
|
|
||||||
"mac VARCHAR, hostname VARCHAR, os VARCHAR, services JSON,"
|
|
||||||
"suggested_type VARCHAR,"
|
|
||||||
"status VARCHAR,"
|
|
||||||
"discovery_source VARCHAR,"
|
|
||||||
"ieee_address VARCHAR,"
|
|
||||||
"friendly_name VARCHAR,"
|
|
||||||
"device_subtype VARCHAR,"
|
|
||||||
"model VARCHAR,"
|
|
||||||
"vendor VARCHAR,"
|
|
||||||
"lqi INTEGER,"
|
|
||||||
"properties JSON,"
|
|
||||||
"discovered_at DATETIME"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO pending_devices_new "
|
|
||||||
"(id, ip, mac, hostname, os, services, suggested_type, status, "
|
|
||||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
|
||||||
"model, vendor, lqi, discovered_at) "
|
|
||||||
"SELECT id, ip, mac, hostname, os, services, suggested_type, status, "
|
|
||||||
"discovery_source, ieee_address, friendly_name, device_subtype, "
|
|
||||||
"model, vendor, lqi, discovered_at FROM pending_devices"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql("DROP TABLE pending_devices")
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"ALTER TABLE pending_devices_new RENAME TO pending_devices"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE INDEX IF NOT EXISTS ix_pending_devices_ieee_address "
|
|
||||||
"ON pending_devices(ieee_address)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
|
||||||
except OperationalError as exc:
|
|
||||||
logger.warning("pending_devices ip-nullable rebuild failed: %s", exc)
|
|
||||||
# --- end Zigbee schema migrations -------------------------------------
|
|
||||||
# --- Electrical designs schema migrations -----------------------------
|
|
||||||
# Create designs table (idempotent)
|
|
||||||
await _try_migrate(
|
|
||||||
conn,
|
|
||||||
"CREATE TABLE IF NOT EXISTS designs ("
|
|
||||||
"id VARCHAR PRIMARY KEY,"
|
|
||||||
"name VARCHAR NOT NULL,"
|
|
||||||
"design_type VARCHAR NOT NULL DEFAULT 'network',"
|
|
||||||
"created_at DATETIME,"
|
|
||||||
"updated_at DATETIME"
|
|
||||||
")",
|
|
||||||
label="designs.table",
|
|
||||||
)
|
|
||||||
# Add user-chosen icon to designs (idempotent), then backfill existing rows
|
|
||||||
# so legacy designs keep a sensible icon based on their original type.
|
|
||||||
await _try_migrate(
|
|
||||||
conn, "ALTER TABLE designs ADD COLUMN icon VARCHAR", label="designs.icon",
|
|
||||||
)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE designs SET icon = 'zap' WHERE icon IS NULL AND design_type = 'electrical'"
|
|
||||||
)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE designs SET icon = 'dashboard' WHERE icon IS NULL"
|
|
||||||
)
|
|
||||||
# Seed default Network Topology design if designs table is empty
|
|
||||||
_default_design_id = str(_uuid_mod.uuid4())
|
|
||||||
row = await conn.exec_driver_sql("SELECT COUNT(*) FROM designs")
|
|
||||||
count_row = row.fetchone()
|
|
||||||
count = count_row[0] if count_row else 0
|
|
||||||
if count == 0:
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
|
|
||||||
"VALUES (?, 'Network Topology', 'network', 'dashboard', datetime('now'), datetime('now'))",
|
|
||||||
(_default_design_id,),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
row2 = await conn.exec_driver_sql("SELECT id FROM designs WHERE design_type = 'network' LIMIT 1")
|
|
||||||
default = row2.fetchone()
|
|
||||||
_default_design_id = default[0] if default else _default_design_id
|
|
||||||
|
|
||||||
# Add design_id to nodes
|
|
||||||
await _try_migrate(
|
|
||||||
conn, "ALTER TABLE nodes ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
|
||||||
label="nodes.design_id",
|
|
||||||
)
|
|
||||||
# Assign existing nodes to default design
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE nodes SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add design_id to edges
|
|
||||||
await _try_migrate(
|
|
||||||
conn, "ALTER TABLE edges ADD COLUMN design_id VARCHAR REFERENCES designs(id)",
|
|
||||||
label="edges.design_id",
|
|
||||||
)
|
|
||||||
# Assign existing edges to default design
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE edges SET design_id = ? WHERE design_id IS NULL", (_default_design_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Migrate canvas_state from id=1 to design_id PK (SQLite rebuild)
|
|
||||||
try:
|
|
||||||
info = await conn.exec_driver_sql("PRAGMA table_info(canvas_state)")
|
|
||||||
cols = info.fetchall()
|
|
||||||
has_design_id = any(c[1] == "design_id" for c in cols)
|
|
||||||
if not has_design_id:
|
|
||||||
logger.info("Migrating canvas_state: switching to design_id primary key")
|
|
||||||
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE TABLE canvas_state_new ("
|
|
||||||
"design_id VARCHAR PRIMARY KEY REFERENCES designs(id) ON DELETE CASCADE,"
|
|
||||||
"viewport JSON,"
|
|
||||||
"custom_style JSON,"
|
|
||||||
"saved_at DATETIME"
|
|
||||||
")"
|
|
||||||
)
|
|
||||||
# Copy existing row(s), mapping id=1 to default design_id
|
|
||||||
old_rows = await conn.exec_driver_sql("SELECT id, viewport, custom_style, saved_at FROM canvas_state")
|
|
||||||
for old in old_rows.fetchall():
|
|
||||||
cs_id, viewport, custom_style, saved_at = old
|
|
||||||
target_design = _default_design_id
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO canvas_state_new (design_id, viewport, custom_style, saved_at) "
|
|
||||||
"VALUES (?, ?, ?, ?)",
|
|
||||||
(target_design, viewport, custom_style, saved_at),
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql("DROP TABLE canvas_state")
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE canvas_state_new RENAME TO canvas_state")
|
|
||||||
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
|
|
||||||
except OperationalError as exc:
|
|
||||||
logger.warning("canvas_state migration failed: %s", exc)
|
|
||||||
# --- end Electrical designs schema migrations --------------------------
|
|
||||||
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN waypoints JSON")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN properties JSON")
|
|
||||||
# Migrate hardware columns → properties JSON (idempotent: only runs on nodes where properties IS NULL)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
rows = await conn.exec_driver_sql(
|
|
||||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
|
||||||
"FROM nodes WHERE properties IS NULL"
|
|
||||||
)
|
|
||||||
for r in rows.fetchall():
|
|
||||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = r
|
|
||||||
props = []
|
|
||||||
visible = bool(show_hardware)
|
|
||||||
if cpu_model:
|
|
||||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
|
||||||
if cpu_count is not None:
|
|
||||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
|
||||||
if ram_gb is not None:
|
|
||||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
|
||||||
if disk_gb is not None:
|
|
||||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
|
||||||
(_json.dumps(props), node_id),
|
|
||||||
)
|
|
||||||
# Inventory timestamp: last time a scan observed this node (idempotent)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN last_scan DATETIME")
|
|
||||||
# Migrate animated column from boolean (0/1) to string ('none'/'snake')
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("UPDATE edges SET animated = 'snake' WHERE animated = '1' OR animated = 1")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
|
||||||
await conn.exec_driver_sql(sql)
|
|
||||||
# Multi-source discovery tags: a device found by both an IP scan and a
|
|
||||||
# Proxmox import carries every source. Backfill from the legacy single
|
|
||||||
# discovery_source so existing rows show under their filter.
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
|
|
||||||
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
|
|
||||||
)
|
|
||||||
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
|
|
||||||
# but a real IP — treat them as an ARP scan so they keep the IP tag.
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
|
|
||||||
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
|
|
||||||
)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
|
|
||||||
)
|
|
||||||
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
|
|
||||||
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
|
||||||
)
|
|
||||||
with suppress(OperationalError):
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
|||||||
@@ -16,24 +16,12 @@ def _uuid() -> str:
|
|||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
class Design(Base):
|
|
||||||
__tablename__ = "designs"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
|
||||||
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
||||||
design_type: Mapped[str] = mapped_column(String, nullable=False, default="network")
|
|
||||||
icon: Mapped[str | None] = mapped_column(String, nullable=True, default="dashboard")
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
|
||||||
|
|
||||||
|
|
||||||
class Node(Base):
|
class Node(Base):
|
||||||
__tablename__ = "nodes"
|
__tablename__ = "nodes"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
type: Mapped[str] = mapped_column(String, nullable=False)
|
type: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
label: Mapped[str] = mapped_column(String, nullable=False)
|
label: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
ip: Mapped[str | None] = mapped_column(String)
|
ip: Mapped[str | None] = mapped_column(String)
|
||||||
mac: Mapped[str | None] = mapped_column(String)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
@@ -45,7 +33,7 @@ class Node(Base):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text)
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
pos_x: Mapped[float] = mapped_column(Float, default=0)
|
||||||
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
pos_y: Mapped[float] = mapped_column(Float, default=0)
|
||||||
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id"))
|
||||||
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||||
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
@@ -54,20 +42,13 @@ class Node(Base):
|
|||||||
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
||||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
||||||
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
width: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
height: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
|
|
||||||
top_handles: Mapped[int] = mapped_column(Integer, default=1)
|
|
||||||
left_handles: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
right_handles: Mapped[int] = mapped_column(Integer, default=0)
|
|
||||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
|
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
|
||||||
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
response_time_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
|
||||||
|
|
||||||
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
|
||||||
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
parent: Mapped["Node | None"] = relationship("Node", back_populates="children", remote_side=[id])
|
||||||
|
|
||||||
@@ -78,93 +59,37 @@ class Edge(Base):
|
|||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
source: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
target: Mapped[str] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
|
||||||
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
|
|
||||||
type: Mapped[str] = mapped_column(String, default="ethernet")
|
type: Mapped[str] = mapped_column(String, default="ethernet")
|
||||||
label: Mapped[str | None] = mapped_column(String)
|
label: Mapped[str | None] = mapped_column(String)
|
||||||
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
vlan_id: Mapped[int | None] = mapped_column(Integer)
|
||||||
speed: Mapped[str | None] = mapped_column(String)
|
speed: Mapped[str | None] = mapped_column(String)
|
||||||
custom_color: Mapped[str | None] = mapped_column(String)
|
custom_color: Mapped[str | None] = mapped_column(String)
|
||||||
path_style: Mapped[str | None] = mapped_column(String)
|
path_style: Mapped[str | None] = mapped_column(String)
|
||||||
line_style: Mapped[str | None] = mapped_column(String)
|
animated: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
width_mult: Mapped[float | None] = mapped_column(Float)
|
|
||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
|
||||||
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
|
||||||
marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
|
||||||
source_handle: Mapped[str | None] = mapped_column(String)
|
source_handle: Mapped[str | None] = mapped_column(String)
|
||||||
target_handle: Mapped[str | None] = mapped_column(String)
|
target_handle: Mapped[str | None] = mapped_column(String)
|
||||||
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
class CanvasState(Base):
|
class CanvasState(Base):
|
||||||
__tablename__ = "canvas_state"
|
__tablename__ = "canvas_state"
|
||||||
|
|
||||||
design_id: Mapped[str] = mapped_column(String, ForeignKey("designs.id", ondelete="CASCADE"), primary_key=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||||
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
viewport: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
custom_style: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
|
||||||
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
saved_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
class PendingDevice(Base):
|
class PendingDevice(Base):
|
||||||
__tablename__ = "pending_devices"
|
__tablename__ = "pending_devices"
|
||||||
# Permit the plain (non-Mapped[]) annotations on the transient request-only
|
|
||||||
# attributes below; without this SQLAlchemy 2.0 tries to map them as columns.
|
|
||||||
__allow_unmapped__ = True
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
ip: Mapped[str | None] = mapped_column(String, nullable=True)
|
ip: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
mac: Mapped[str | None] = mapped_column(String)
|
mac: Mapped[str | None] = mapped_column(String)
|
||||||
hostname: Mapped[str | None] = mapped_column(String)
|
hostname: Mapped[str | None] = mapped_column(String)
|
||||||
os: Mapped[str | None] = mapped_column(String)
|
os: Mapped[str | None] = mapped_column(String)
|
||||||
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
||||||
suggested_type: Mapped[str | None] = mapped_column(String)
|
suggested_type: Mapped[str | None] = mapped_column(String)
|
||||||
status: Mapped[str] = mapped_column(String, default="pending")
|
status: Mapped[str] = mapped_column(String, default="pending")
|
||||||
# Origin/primary source (first discovery): "arp"/"mdns"/"zigbee"/"zwave"/
|
|
||||||
# "proxmox". Kept for back-compat; `discovery_sources` is the full set.
|
|
||||||
discovery_source: Mapped[str | None] = mapped_column(String)
|
|
||||||
# All sources that have observed this device. A device found by both an IP
|
|
||||||
# scan and a Proxmox import carries e.g. ["arp", "proxmox"] and shows under
|
|
||||||
# both inventory filters. Source of truth for the frontend source badges.
|
|
||||||
discovery_sources: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
||||||
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
|
|
||||||
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
model: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
|
|
||||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
# Display properties carried from discovery (e.g. Proxmox specs: CPU/RAM/Disk,
|
|
||||||
# VMID). Generic NodeProperty shape {key,value,icon,visible}; merged into the
|
|
||||||
# Node's properties on approve. Empty for scan/mesh sources that don't set it.
|
|
||||||
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
|
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
|
||||||
|
|
||||||
# Transient (not persisted): populated per-request by the scan routes to report
|
|
||||||
# how many canvases this device already appears on. Not a mapped column.
|
|
||||||
canvas_count: int = 0
|
|
||||||
# Transient (not persisted): timestamps from the linked canvas node(s),
|
|
||||||
# correlated by ip / ieee_address. None when the device is not on any canvas.
|
|
||||||
node_created_at: datetime | None = None
|
|
||||||
node_last_scan: datetime | None = None
|
|
||||||
node_last_modified: datetime | None = None
|
|
||||||
node_last_seen: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PendingDeviceLink(Base):
|
|
||||||
"""Link between two Zigbee endpoints discovered during import.
|
|
||||||
|
|
||||||
Endpoints are addressed by IEEE (stable across re-imports). Either side may
|
|
||||||
already exist as a canvas Node (resolved via Node.ieee_address) or still be
|
|
||||||
a PendingDevice. On approval, the matching Edge is auto-created when both
|
|
||||||
endpoints exist as canvas Nodes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "pending_device_links"
|
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
|
||||||
source_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
|
||||||
target_ieee: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
|
||||||
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
||||||
discovery_source: Mapped[str] = mapped_column(String, nullable=False, default="zigbee")
|
|
||||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +98,6 @@ class ScanRun(Base):
|
|||||||
|
|
||||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
|
||||||
status: Mapped[str] = mapped_column(String, default="running")
|
status: Mapped[str] = mapped_column(String, default="running")
|
||||||
kind: Mapped[str] = mapped_column(String, default="ip", server_default="ip")
|
|
||||||
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
ranges: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||||
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
devices_found: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|||||||
+2
-37
@@ -1,5 +1,3 @@
|
|||||||
import logging
|
|
||||||
import logging.config
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -7,22 +5,7 @@ from typing import Any
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import (
|
from app.api.routes import auth, canvas, edges, nodes, scan, status
|
||||||
auth,
|
|
||||||
canvas,
|
|
||||||
designs,
|
|
||||||
edges,
|
|
||||||
liveview,
|
|
||||||
media,
|
|
||||||
nodes,
|
|
||||||
proxmox,
|
|
||||||
scan,
|
|
||||||
stats,
|
|
||||||
status,
|
|
||||||
zigbee,
|
|
||||||
zwave,
|
|
||||||
)
|
|
||||||
from app.api.routes import settings as settings_routes
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.scheduler import start_scheduler, stop_scheduler
|
from app.core.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
@@ -30,16 +13,6 @@ from app.db.database import init_db
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
# Ensure app logs are visible: attach a handler to the root logger if none
|
|
||||||
# exists (uvicorn only installs handlers on its own loggers, not the root).
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
if not root_logger.handlers:
|
|
||||||
handler = logging.StreamHandler()
|
|
||||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
|
||||||
root_logger.addHandler(handler)
|
|
||||||
root_logger.setLevel(logging.INFO)
|
|
||||||
logging.getLogger("app").setLevel(logging.INFO)
|
|
||||||
logging.getLogger("app.services.scanner").setLevel(logging.INFO)
|
|
||||||
await init_db()
|
await init_db()
|
||||||
settings.load_overrides()
|
settings.load_overrides()
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
@@ -49,7 +22,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Homelable API",
|
title="Homelable API",
|
||||||
version="1.9.0",
|
version="1.3.3",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,16 +38,8 @@ app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
|||||||
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
app.include_router(nodes.router, prefix="/api/v1/nodes", tags=["nodes"])
|
||||||
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
app.include_router(edges.router, prefix="/api/v1/edges", tags=["edges"])
|
||||||
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
app.include_router(canvas.router, prefix="/api/v1/canvas", tags=["canvas"])
|
||||||
app.include_router(designs.router, prefix="/api/v1/designs", tags=["designs"])
|
|
||||||
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
app.include_router(scan.router, prefix="/api/v1/scan", tags=["scan"])
|
||||||
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
app.include_router(status.router, prefix="/api/v1/status", tags=["status"])
|
||||||
app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["settings"])
|
|
||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
|
||||||
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
|
||||||
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
|
||||||
app.include_router(proxmox.router, prefix="/api/v1/proxmox", tags=["proxmox"])
|
|
||||||
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
|
|
||||||
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.schemas.edges import EdgeResponse
|
from app.schemas.edges import EdgeResponse
|
||||||
from app.schemas.nodes import NodeResponse
|
from app.schemas.nodes import NodeResponse
|
||||||
from app.schemas.utils import normalize_animated, normalize_marker
|
|
||||||
|
|
||||||
|
|
||||||
class NodeSave(BaseModel):
|
class NodeSave(BaseModel):
|
||||||
@@ -29,14 +28,8 @@ class NodeSave(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
show_port_numbers: bool = False
|
|
||||||
properties: list[Any] = []
|
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
|
||||||
top_handles: int = 1
|
|
||||||
left_handles: int = 0
|
|
||||||
right_handles: int = 0
|
|
||||||
pos_x: float = 0
|
pos_x: float = 0
|
||||||
pos_y: float = 0
|
pos_y: float = 0
|
||||||
|
|
||||||
@@ -51,41 +44,18 @@ class EdgeSave(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
line_style: str | None = None
|
animated: bool = False
|
||||||
width_mult: float | None = None
|
|
||||||
animated: str = 'none'
|
|
||||||
marker_start: str = 'none'
|
|
||||||
marker_end: str = 'none'
|
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_animated(cls, v: object) -> str:
|
|
||||||
return normalize_animated(v)
|
|
||||||
|
|
||||||
@field_validator('marker_start', 'marker_end', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_marker(cls, v: object) -> str:
|
|
||||||
return normalize_marker(v)
|
|
||||||
|
|
||||||
|
|
||||||
class CanvasSaveRequest(BaseModel):
|
class CanvasSaveRequest(BaseModel):
|
||||||
nodes: list[NodeSave] = []
|
nodes: list[NodeSave] = []
|
||||||
edges: list[EdgeSave] = []
|
edges: list[EdgeSave] = []
|
||||||
viewport: dict[str, Any] = {}
|
viewport: dict[str, Any] = {}
|
||||||
custom_style: dict[str, Any] | None = None
|
|
||||||
design_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CanvasStateResponse(BaseModel):
|
class CanvasStateResponse(BaseModel):
|
||||||
nodes: list[NodeResponse]
|
nodes: list[NodeResponse]
|
||||||
edges: list[EdgeResponse]
|
edges: list[EdgeResponse]
|
||||||
viewport: dict[str, Any]
|
viewport: dict[str, Any]
|
||||||
custom_style: dict[str, Any] | None = None
|
|
||||||
# True once this design's canvas has ever been persisted (a CanvasState row
|
|
||||||
# exists). Lets the frontend tell a brand-new user (show demo) apart from one
|
|
||||||
# who intentionally cleared their canvas (keep it empty). False also for a
|
|
||||||
# missing/uninitialized design.
|
|
||||||
initialized: bool = False
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
class DesignCreate(BaseModel):
|
|
||||||
name: str
|
|
||||||
icon: str = "dashboard"
|
|
||||||
# Vestigial: kept for backward compatibility. The UI no longer branches on it;
|
|
||||||
# the chosen icon now drives presentation. Defaults to a generic canvas.
|
|
||||||
design_type: str = "network"
|
|
||||||
|
|
||||||
|
|
||||||
class DesignUpdate(BaseModel):
|
|
||||||
name: str | None = None
|
|
||||||
icon: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DesignCopy(BaseModel):
|
|
||||||
"""Create a new design by deep-copying an existing one's canvas."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
icon: str = "dashboard"
|
|
||||||
|
|
||||||
|
|
||||||
class DesignResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
design_type: str
|
|
||||||
icon: str | None = None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
# Populated by list_designs so the "copy from existing" picker can show what
|
|
||||||
# each canvas holds. None on create/update/copy responses (not computed there).
|
|
||||||
node_count: int | None = None
|
|
||||||
group_count: int | None = None
|
|
||||||
text_count: int | None = None
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.schemas.utils import normalize_animated, normalize_marker
|
|
||||||
|
|
||||||
|
|
||||||
class EdgeBase(BaseModel):
|
class EdgeBase(BaseModel):
|
||||||
@@ -14,28 +12,13 @@ class EdgeBase(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
line_style: str | None = None
|
animated: bool = False
|
||||||
width_mult: float | None = None
|
|
||||||
animated: str = 'none'
|
|
||||||
marker_start: str = 'none'
|
|
||||||
marker_end: str = 'none'
|
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_animated(cls, v: object) -> str:
|
|
||||||
return normalize_animated(v)
|
|
||||||
|
|
||||||
@field_validator('marker_start', 'marker_end', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_marker(cls, v: object) -> str:
|
|
||||||
return normalize_marker(v)
|
|
||||||
|
|
||||||
|
|
||||||
class EdgeCreate(EdgeBase):
|
class EdgeCreate(EdgeBase):
|
||||||
design_id: str | None = None
|
pass
|
||||||
|
|
||||||
|
|
||||||
class EdgeUpdate(BaseModel):
|
class EdgeUpdate(BaseModel):
|
||||||
@@ -45,33 +28,13 @@ class EdgeUpdate(BaseModel):
|
|||||||
speed: str | None = None
|
speed: str | None = None
|
||||||
custom_color: str | None = None
|
custom_color: str | None = None
|
||||||
path_style: str | None = None
|
path_style: str | None = None
|
||||||
line_style: str | None = None
|
animated: bool | None = None
|
||||||
width_mult: float | None = None
|
|
||||||
animated: str | None = None
|
|
||||||
marker_start: str | None = None
|
|
||||||
marker_end: str | None = None
|
|
||||||
source_handle: str | None = None
|
source_handle: str | None = None
|
||||||
target_handle: str | None = None
|
target_handle: str | None = None
|
||||||
waypoints: list[dict[str, float]] | None = None
|
|
||||||
|
|
||||||
@field_validator('animated', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_animated(cls, v: object) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
return normalize_animated(v)
|
|
||||||
|
|
||||||
@field_validator('marker_start', 'marker_end', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def validate_marker(cls, v: object) -> str | None:
|
|
||||||
if v is None:
|
|
||||||
return None
|
|
||||||
return normalize_marker(v)
|
|
||||||
|
|
||||||
|
|
||||||
class EdgeResponse(EdgeBase):
|
class EdgeResponse(EdgeBase):
|
||||||
id: str
|
id: str
|
||||||
design_id: str | None = None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -27,26 +27,12 @@ class NodeBase(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool = False
|
show_hardware: bool = False
|
||||||
show_port_numbers: bool = False
|
|
||||||
properties: list[dict[str, Any]] = []
|
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
|
||||||
top_handles: int = 1
|
|
||||||
left_handles: int = 0
|
|
||||||
right_handles: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(NodeBase):
|
class NodeCreate(NodeBase):
|
||||||
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
|
pass
|
||||||
# The create_node route resolves None to a free grid slot before persisting.
|
|
||||||
pos_x: float | None = None # type: ignore[assignment]
|
|
||||||
pos_y: float | None = None # type: ignore[assignment]
|
|
||||||
design_id: str | None = None
|
|
||||||
# When a node with the same ip/mac already exists on the target design, the
|
|
||||||
# create/approve endpoints reject with 409 so the UI can ask the user. Set
|
|
||||||
# force=True to bypass that guard and create the duplicate deliberately.
|
|
||||||
force: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class NodeUpdate(BaseModel):
|
class NodeUpdate(BaseModel):
|
||||||
@@ -72,22 +58,13 @@ class NodeUpdate(BaseModel):
|
|||||||
ram_gb: float | None = None
|
ram_gb: float | None = None
|
||||||
disk_gb: float | None = None
|
disk_gb: float | None = None
|
||||||
show_hardware: bool | None = None
|
show_hardware: bool | None = None
|
||||||
show_port_numbers: bool | None = None
|
|
||||||
properties: list[dict[str, Any]] | None = None
|
|
||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int | None = None
|
|
||||||
top_handles: int | None = None
|
|
||||||
left_handles: int | None = None
|
|
||||||
right_handles: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class NodeResponse(NodeBase):
|
class NodeResponse(NodeBase):
|
||||||
id: str
|
id: str
|
||||||
design_id: str | None = None
|
|
||||||
ieee_address: str | None = None
|
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
last_scan: datetime | None = None
|
|
||||||
response_time_ms: int | None = None
|
response_time_ms: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""Pydantic v2 schemas for Proxmox VE import.
|
|
||||||
|
|
||||||
Token fields are accepted on requests only and are optional — when omitted the
|
|
||||||
backend falls back to the server-configured token (env). No response schema ever
|
|
||||||
carries a token; secrets are kept out of responses by structural omission.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxConnectionRequest(BaseModel):
|
|
||||||
host: str = Field(..., description="Proxmox VE host or IP")
|
|
||||||
port: int = Field(8006, ge=1, le=65535, description="Proxmox API port")
|
|
||||||
token_id: str | None = Field(
|
|
||||||
None, description="API token id 'user@realm!tokenname' (falls back to server env)"
|
|
||||||
)
|
|
||||||
token_secret: str | None = Field(
|
|
||||||
None, description="API token secret (falls back to server env)"
|
|
||||||
)
|
|
||||||
verify_tls: bool = Field(True, description="Verify the Proxmox TLS certificate")
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxTestConnectionResponse(BaseModel):
|
|
||||||
connected: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxNodeOut(BaseModel):
|
|
||||||
"""A homelable-ready node representation of a Proxmox host / VM / LXC."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
type: str # proxmox | vm | lxc
|
|
||||||
ieee_address: str
|
|
||||||
hostname: str | None = None
|
|
||||||
ip: str | None = None
|
|
||||||
status: str
|
|
||||||
cpu_count: int | None = None
|
|
||||||
ram_gb: float | None = None
|
|
||||||
disk_gb: float | None = None
|
|
||||||
vendor: str | None = None
|
|
||||||
model: str | None = None
|
|
||||||
parent_ieee: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxEdgeOut(BaseModel):
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxImportResponse(BaseModel):
|
|
||||||
nodes: list[ProxmoxNodeOut]
|
|
||||||
edges: list[ProxmoxEdgeOut]
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxImportPendingResponse(BaseModel):
|
|
||||||
"""Result of importing a Proxmox inventory into the pending section."""
|
|
||||||
|
|
||||||
pending_created: int
|
|
||||||
pending_updated: int
|
|
||||||
links_recorded: int
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxConfig(BaseModel):
|
|
||||||
"""Non-secret Proxmox connection + auto-sync config (GET response).
|
|
||||||
|
|
||||||
Connection fields (host/port/verify_tls) are env-only and read-only here —
|
|
||||||
surfaced for display. ``token_configured`` reflects whether a server-side
|
|
||||||
token is present. Never carries the token itself."""
|
|
||||||
|
|
||||||
host: str = ""
|
|
||||||
port: int = Field(8006, ge=1, le=65535)
|
|
||||||
verify_tls: bool = True
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
token_configured: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ProxmoxSyncConfig(BaseModel):
|
|
||||||
"""User-editable auto-sync config (POST body). The ONLY persisted Proxmox
|
|
||||||
settings. Connection fields (host/port/token/verify_tls) are env-only and
|
|
||||||
are deliberately not accepted here."""
|
|
||||||
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
@@ -1,48 +1,19 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
class PendingDeviceResponse(BaseModel):
|
class PendingDeviceResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
ip: str | None
|
ip: str
|
||||||
mac: str | None
|
mac: str | None
|
||||||
hostname: str | None
|
hostname: str | None
|
||||||
os: str | None
|
os: str | None
|
||||||
services: list[Any]
|
services: list[Any]
|
||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
discovery_source: str | None
|
|
||||||
# All sources that have observed this device (e.g. ["arp", "proxmox"]). Drives
|
|
||||||
# the inventory source filter + badges; falls back to [discovery_source].
|
|
||||||
discovery_sources: list[str] = []
|
|
||||||
ieee_address: str | None = None
|
|
||||||
friendly_name: str | None = None
|
|
||||||
device_subtype: str | None = None
|
|
||||||
model: str | None = None
|
|
||||||
vendor: str | None = None
|
|
||||||
lqi: int | None = None
|
|
||||||
# Display properties carried from discovery (e.g. Proxmox specs). Merged into
|
|
||||||
# the node on approve; empty for scan/mesh sources that don't set them.
|
|
||||||
properties: list[Any] = []
|
|
||||||
discovered_at: datetime
|
discovered_at: datetime
|
||||||
# Number of distinct canvases (designs) this device already appears on,
|
|
||||||
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
|
||||||
canvas_count: int = 0
|
|
||||||
# Timestamps from the linked canvas node(s), correlated by ip / ieee_address.
|
|
||||||
# Null when the device is not on any canvas yet. Aggregated across matches:
|
|
||||||
# created_at = oldest; last_scan / last_modified / last_seen = newest.
|
|
||||||
node_created_at: datetime | None = None
|
|
||||||
node_last_scan: datetime | None = None
|
|
||||||
node_last_modified: datetime | None = None
|
|
||||||
node_last_seen: datetime | None = None
|
|
||||||
|
|
||||||
@field_validator("properties", "discovery_sources", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _coerce_list(cls, v: Any) -> list[Any]:
|
|
||||||
# Legacy rows (columns added by migration) have these = NULL.
|
|
||||||
return v if isinstance(v, list) else []
|
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
@@ -50,7 +21,6 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
class ScanRunResponse(BaseModel):
|
class ScanRunResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
status: str
|
status: str
|
||||||
kind: str = "ip"
|
|
||||||
ranges: list[str]
|
ranges: list[str]
|
||||||
devices_found: int
|
devices_found: int
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
def normalize_animated(v: object) -> str:
|
|
||||||
"""Normalize legacy bool/int animated values to string mode ('none'/'snake'/'flow')."""
|
|
||||||
if v is True or v == 1 or v == '1':
|
|
||||||
return 'snake'
|
|
||||||
if v is False or v == 0 or v == '0' or v is None or v == 'none':
|
|
||||||
return 'none'
|
|
||||||
if v in ('snake', 'flow', 'basic'):
|
|
||||||
return str(v)
|
|
||||||
return 'none'
|
|
||||||
|
|
||||||
|
|
||||||
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_marker(v: object) -> str:
|
|
||||||
"""Normalize an edge endpoint marker to a shape string.
|
|
||||||
|
|
||||||
Legacy saves stored a boolean (True = filled arrow); coerce those and any
|
|
||||||
unknown value to a valid MarkerShape ('none' when off/unknown).
|
|
||||||
"""
|
|
||||||
if v is True or v == 1 or v == '1':
|
|
||||||
return 'arrow'
|
|
||||||
if v is False or v == 0 or v == '0' or v is None:
|
|
||||||
return 'none'
|
|
||||||
if isinstance(v, str) and v in MARKER_SHAPES:
|
|
||||||
return v
|
|
||||||
return 'none'
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
"""Pydantic v2 schemas for Zigbee2MQTT import."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeImportRequest(BaseModel):
|
|
||||||
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
|
||||||
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
|
||||||
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
|
||||||
base_topic: str = Field("zigbee2mqtt", description="Zigbee2MQTT base topic")
|
|
||||||
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
|
||||||
mqtt_tls_insecure: bool = Field(
|
|
||||||
False, description="Skip TLS certificate verification (self-signed only)"
|
|
||||||
)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _insecure_requires_tls(self) -> "ZigbeeImportRequest":
|
|
||||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
|
||||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeTestConnectionRequest(BaseModel):
|
|
||||||
mqtt_host: str
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
|
||||||
mqtt_username: str | None = None
|
|
||||||
mqtt_password: str | None = None
|
|
||||||
mqtt_tls: bool = False
|
|
||||||
mqtt_tls_insecure: bool = False
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _insecure_requires_tls(self) -> "ZigbeeTestConnectionRequest":
|
|
||||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
|
||||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeDeviceData(BaseModel):
|
|
||||||
ieee_address: str
|
|
||||||
friendly_name: str
|
|
||||||
device_type: str # Coordinator, Router, EndDevice
|
|
||||||
model: str | None = None
|
|
||||||
vendor: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
lqi: int | None = None
|
|
||||||
last_seen: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeNodeOut(BaseModel):
|
|
||||||
"""A homelable-ready node representation of a Zigbee device."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
type: str # zigbee_coordinator | zigbee_router | zigbee_enddevice
|
|
||||||
ieee_address: str
|
|
||||||
friendly_name: str
|
|
||||||
device_type: str
|
|
||||||
model: str | None = None
|
|
||||||
vendor: str | None = None
|
|
||||||
lqi: int | None = None
|
|
||||||
parent_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeEdgeOut(BaseModel):
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeImportResponse(BaseModel):
|
|
||||||
nodes: list[ZigbeeNodeOut]
|
|
||||||
edges: list[ZigbeeEdgeOut]
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeTestConnectionResponse(BaseModel):
|
|
||||||
connected: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeCoordinatorOut(BaseModel):
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
ieee_address: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeImportPendingResponse(BaseModel):
|
|
||||||
"""Result of importing a Z2M network into the pending section."""
|
|
||||||
|
|
||||||
pending_created: int
|
|
||||||
pending_updated: int
|
|
||||||
coordinator: ZigbeeCoordinatorOut | None = None
|
|
||||||
coordinator_already_existed: bool = False
|
|
||||||
links_recorded: int
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeConfig(BaseModel):
|
|
||||||
"""Non-secret Zigbee connection + auto-sync config (GET response).
|
|
||||||
|
|
||||||
MQTT connection fields (host/port/base_topic/tls) are env-only and
|
|
||||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
|
||||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
|
||||||
(username/password) are never carried."""
|
|
||||||
|
|
||||||
mqtt_host: str = ""
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
|
||||||
base_topic: str = "zigbee2mqtt"
|
|
||||||
mqtt_tls: bool = False
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
host_configured: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ZigbeeSyncConfig(BaseModel):
|
|
||||||
"""User-editable auto-sync config (POST body). The ONLY persisted Zigbee
|
|
||||||
settings. Connection fields (host/port/credentials/topic/tls) are env-only
|
|
||||||
and are deliberately not accepted here."""
|
|
||||||
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Pydantic v2 schemas for Z-Wave JS UI (zwavejs2mqtt) import."""
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveImportRequest(BaseModel):
|
|
||||||
mqtt_host: str = Field(..., description="MQTT broker hostname or IP address")
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535, description="MQTT broker port")
|
|
||||||
mqtt_username: str | None = Field(None, description="MQTT username (optional)")
|
|
||||||
mqtt_password: str | None = Field(None, description="MQTT password (optional)")
|
|
||||||
prefix: str = Field("zwave", description="Z-Wave JS UI MQTT prefix")
|
|
||||||
gateway_name: str = Field("zwavejs2mqtt", description="Z-Wave JS UI gateway name")
|
|
||||||
mqtt_tls: bool = Field(False, description="Enable TLS (typically port 8883)")
|
|
||||||
mqtt_tls_insecure: bool = Field(
|
|
||||||
False, description="Skip TLS certificate verification (self-signed only)"
|
|
||||||
)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _insecure_requires_tls(self) -> "ZwaveImportRequest":
|
|
||||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
|
||||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveTestConnectionRequest(BaseModel):
|
|
||||||
mqtt_host: str
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
|
||||||
mqtt_username: str | None = None
|
|
||||||
mqtt_password: str | None = None
|
|
||||||
mqtt_tls: bool = False
|
|
||||||
mqtt_tls_insecure: bool = False
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def _insecure_requires_tls(self) -> "ZwaveTestConnectionRequest":
|
|
||||||
if self.mqtt_tls_insecure and not self.mqtt_tls:
|
|
||||||
raise ValueError("mqtt_tls_insecure requires mqtt_tls=true")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveNodeOut(BaseModel):
|
|
||||||
"""A homelable-ready node representation of a Z-Wave device."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
type: str # zwave_coordinator | zwave_router | zwave_enddevice
|
|
||||||
ieee_address: str
|
|
||||||
friendly_name: str
|
|
||||||
device_type: str
|
|
||||||
model: str | None = None
|
|
||||||
vendor: str | None = None
|
|
||||||
lqi: int | None = None
|
|
||||||
parent_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveEdgeOut(BaseModel):
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveImportResponse(BaseModel):
|
|
||||||
nodes: list[ZwaveNodeOut]
|
|
||||||
edges: list[ZwaveEdgeOut]
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveTestConnectionResponse(BaseModel):
|
|
||||||
connected: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveCoordinatorOut(BaseModel):
|
|
||||||
id: str
|
|
||||||
label: str
|
|
||||||
ieee_address: str
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveImportPendingResponse(BaseModel):
|
|
||||||
"""Result of importing a Z-Wave network into the pending section."""
|
|
||||||
|
|
||||||
pending_created: int
|
|
||||||
pending_updated: int
|
|
||||||
coordinator: ZwaveCoordinatorOut | None = None
|
|
||||||
coordinator_already_existed: bool = False
|
|
||||||
links_recorded: int
|
|
||||||
device_count: int
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveConfig(BaseModel):
|
|
||||||
"""Non-secret Z-Wave connection + auto-sync config (GET response).
|
|
||||||
|
|
||||||
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
|
|
||||||
read-only here — surfaced for display. ``host_configured`` reflects whether
|
|
||||||
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
|
||||||
(username/password) are never carried."""
|
|
||||||
|
|
||||||
mqtt_host: str = ""
|
|
||||||
mqtt_port: int = Field(1883, ge=1, le=65535)
|
|
||||||
prefix: str = "zwave"
|
|
||||||
gateway_name: str = "zwavejs2mqtt"
|
|
||||||
mqtt_tls: bool = False
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
host_configured: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class ZwaveSyncConfig(BaseModel):
|
|
||||||
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
|
|
||||||
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
|
|
||||||
env-only and are deliberately not accepted here."""
|
|
||||||
|
|
||||||
sync_enabled: bool = False
|
|
||||||
sync_interval: int = Field(3600, ge=300)
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Helpers for the multi-valued ``PendingDevice.discovery_sources`` set.
|
|
||||||
|
|
||||||
A device discovered by more than one path (e.g. an IP scan *and* a Proxmox
|
|
||||||
import) accumulates every source that has seen it, so it surfaces under each
|
|
||||||
matching inventory filter. Order is preserved (origin first) and duplicates are
|
|
||||||
dropped.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Iterable
|
|
||||||
|
|
||||||
|
|
||||||
def add_source(sources: Iterable[str] | None, source: str | None) -> list[str]:
|
|
||||||
"""Return ``sources`` with ``source`` appended if not already present."""
|
|
||||||
out = [s for s in (sources or []) if s]
|
|
||||||
if source and source not in out:
|
|
||||||
out.append(source)
|
|
||||||
return out
|
|
||||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SIGNATURES: list[dict[str, Any]] | None = None
|
_SIGNATURES: list[dict[str, Any]] | None = None
|
||||||
_OUI_MAP: dict[str, str] | None = None
|
|
||||||
_LOCK = threading.Lock()
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -27,124 +26,25 @@ def _load() -> list[dict[str, Any]]:
|
|||||||
return _SIGNATURES
|
return _SIGNATURES
|
||||||
|
|
||||||
|
|
||||||
def _load_oui() -> dict[str, str]:
|
|
||||||
"""Load OUI database and flatten to {prefix: node_type}."""
|
|
||||||
global _OUI_MAP
|
|
||||||
if _OUI_MAP is None:
|
|
||||||
with _LOCK:
|
|
||||||
if _OUI_MAP is None:
|
|
||||||
path = Path(__file__).parent.parent / "data" / "oui_database.json"
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
entries = json.load(f)
|
|
||||||
except FileNotFoundError as err:
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"oui_database.json not found at {path}. "
|
|
||||||
"This file should be bundled with the application."
|
|
||||||
) from err
|
|
||||||
_OUI_MAP = {
|
|
||||||
prefix.lower(): entry["type"]
|
|
||||||
for entry in entries
|
|
||||||
for prefix in entry["prefixes"]
|
|
||||||
}
|
|
||||||
return _OUI_MAP
|
|
||||||
|
|
||||||
|
|
||||||
def _http_regex_hit(sig: dict[str, Any], http_signals: dict[str, Any] | None) -> bool:
|
|
||||||
"""True when the signature's http_regex matches the probe's title/headers."""
|
|
||||||
rx = sig.get("http_regex")
|
|
||||||
if not rx or not http_signals:
|
|
||||||
return False
|
|
||||||
headers = http_signals.get("headers") or {}
|
|
||||||
haystack = " ".join(
|
|
||||||
s for s in (
|
|
||||||
http_signals.get("title"),
|
|
||||||
headers.get("Server"),
|
|
||||||
headers.get("X-Powered-By"),
|
|
||||||
) if s
|
|
||||||
)
|
|
||||||
return bool(haystack and re.search(rx, haystack, re.IGNORECASE))
|
|
||||||
|
|
||||||
|
|
||||||
def _service_tier(
|
|
||||||
sig: dict[str, Any],
|
|
||||||
port: int,
|
|
||||||
protocol: str,
|
|
||||||
banner: str | None,
|
|
||||||
http_signals: dict[str, Any] | None,
|
|
||||||
) -> int | None:
|
|
||||||
"""
|
|
||||||
Rank how well a signature matches (lower = stronger). None = not a match.
|
|
||||||
|
|
||||||
Tier 1: port match + http_regex confirmed
|
|
||||||
Tier 2: port match + banner_regex confirmed
|
|
||||||
Tier 3: port-agnostic (port: null) + http_regex confirmed
|
|
||||||
Tier 4: port match only (no regex, or http_regex with probe disabled)
|
|
||||||
|
|
||||||
When http_signals is None (probe not run) an http_regex entry degrades to
|
|
||||||
a port-only match — identical to pre-probe behaviour, no regression.
|
|
||||||
When http_signals is provided, http_regex is strict: a miss disqualifies.
|
|
||||||
"""
|
|
||||||
probe_ran = http_signals is not None
|
|
||||||
has_http = bool(sig.get("http_regex"))
|
|
||||||
|
|
||||||
# Port-agnostic entries (port: null) match purely on HTTP signals.
|
|
||||||
if sig.get("port") is None:
|
|
||||||
if has_http and _http_regex_hit(sig, http_signals):
|
|
||||||
return 3
|
|
||||||
return None
|
|
||||||
|
|
||||||
if sig["port"] != port or sig["protocol"] != protocol:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# http_regex is authoritative once a probe has run.
|
|
||||||
if has_http and probe_ran:
|
|
||||||
return 1 if _http_regex_hit(sig, http_signals) else None
|
|
||||||
|
|
||||||
if sig.get("banner_regex"):
|
|
||||||
if banner and re.search(sig["banner_regex"], banner, re.IGNORECASE):
|
|
||||||
return 2
|
|
||||||
return None
|
|
||||||
|
|
||||||
# No regex constraint (or http_regex but probe disabled) → port-only guess.
|
|
||||||
return 4
|
|
||||||
|
|
||||||
|
|
||||||
def match_service(
|
|
||||||
port: int,
|
|
||||||
protocol: str,
|
|
||||||
banner: str | None = None,
|
|
||||||
http_signals: dict[str, Any] | None = None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Return the best signature for a port, walking tiers most-specific first."""
|
|
||||||
best: dict[str, Any] | None = None
|
|
||||||
best_tier = 99
|
|
||||||
for sig in _load():
|
|
||||||
tier = _service_tier(sig, port, protocol, banner, http_signals)
|
|
||||||
if tier is not None and tier < best_tier:
|
|
||||||
best, best_tier = sig, tier
|
|
||||||
if best_tier == 1:
|
|
||||||
break # strongest possible — stop early
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
def match_port(port: int, protocol: str, banner: str | None = None) -> dict[str, Any] | None:
|
||||||
"""Back-compat alias: match without HTTP-probe signals."""
|
"""Return the first signature matching port+protocol, optionally banner."""
|
||||||
return match_service(port, protocol, banner)
|
for sig in _load():
|
||||||
|
if sig["port"] != port or sig["protocol"] != protocol:
|
||||||
|
continue
|
||||||
|
if sig.get("banner_regex") and (not banner or not re.search(sig["banner_regex"], banner, re.IGNORECASE)):
|
||||||
|
continue
|
||||||
|
return sig
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def fingerprint_ports(
|
def fingerprint_ports(open_ports: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
open_ports: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Given a list of {port, protocol, banner?, http_signals?} dicts, return
|
Given a list of {port, protocol, banner?} dicts, return matched services.
|
||||||
matched services. Unknown ports are included as unknown_service.
|
Unknown ports are included as unknown_service.
|
||||||
"""
|
"""
|
||||||
results = []
|
results = []
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
sig = match_service(
|
sig = match_port(p["port"], p.get("protocol", "tcp"), p.get("banner"))
|
||||||
p["port"], p.get("protocol", "tcp"), p.get("banner"), p.get("http_signals")
|
|
||||||
)
|
|
||||||
if sig:
|
if sig:
|
||||||
results.append({
|
results.append({
|
||||||
"port": p["port"],
|
"port": p["port"],
|
||||||
@@ -165,12 +65,23 @@ def fingerprint_ports(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# Known OUI prefixes for virtual machines / hypervisors (lowercase, colon-separated)
|
||||||
|
_MAC_OUI_TYPES: dict[str, str] = {
|
||||||
|
"52:54:00": "vm", # QEMU/KVM (used by Proxmox VMs)
|
||||||
|
"bc:24:11": "vm", # Proxmox official OUI (VMs and LXC, Proxmox 7.3+)
|
||||||
|
"00:50:56": "vm", # VMware
|
||||||
|
"00:0c:29": "vm", # VMware Workstation / Fusion
|
||||||
|
"08:00:27": "vm", # VirtualBox
|
||||||
|
"00:15:5d": "vm", # Hyper-V
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def suggest_type_from_mac(mac: str | None) -> str | None:
|
def suggest_type_from_mac(mac: str | None) -> str | None:
|
||||||
"""Return a suggested node type from MAC OUI, or None if unknown."""
|
"""Return a suggested node type from MAC OUI, or None if unknown."""
|
||||||
if not mac:
|
if not mac:
|
||||||
return None
|
return None
|
||||||
prefix = mac.lower()[:8]
|
prefix = mac.lower()[:8]
|
||||||
return _load_oui().get(prefix)
|
return _MAC_OUI_TYPES.get(prefix)
|
||||||
|
|
||||||
|
|
||||||
_PORT_TYPE_HINTS: dict[int, str] = {
|
_PORT_TYPE_HINTS: dict[int, str] = {
|
||||||
@@ -190,13 +101,10 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
37777: "camera", # Dahua
|
37777: "camera", # Dahua
|
||||||
34567: "camera", # Amcrest
|
34567: "camera", # Amcrest
|
||||||
2020: "camera", # Tapo
|
2020: "camera", # Tapo
|
||||||
# Smart-home / MQTT / CoAP → iot
|
# Smart-home / MQTT → iot
|
||||||
1883: "iot",
|
1883: "iot",
|
||||||
8883: "iot",
|
8883: "iot",
|
||||||
6052: "iot", # ESPHome dashboard
|
6052: "iot", # ESPHome
|
||||||
4915: "iot", # Shelly CoIoT
|
|
||||||
5683: "iot", # CoAP (Shelly Gen1, many IoT devices)
|
|
||||||
5684: "iot", # CoAP DTLS
|
|
||||||
# AP / wireless
|
# AP / wireless
|
||||||
8880: "ap", # UniFi HTTP
|
8880: "ap", # UniFi HTTP
|
||||||
8443: "ap", # UniFi HTTPS
|
8443: "ap", # UniFi HTTPS
|
||||||
@@ -207,13 +115,8 @@ _PORT_TYPE_HINTS: dict[int, str] = {
|
|||||||
|
|
||||||
|
|
||||||
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None) -> str:
|
||||||
"""Suggest a node type based on matched signatures, port hints, and MAC OUI."""
|
"""Suggest a node type based on matched signatures and MAC OUI."""
|
||||||
# IoT vendor MACs are a strong, unambiguous signal — don't let generic HTTP ports override
|
priority = ["proxmox", "nas", "router", "lxc", "vm", "server", "ap", "camera", "iot", "switch"]
|
||||||
mac_type = suggest_type_from_mac(mac)
|
|
||||||
if mac_type == "iot":
|
|
||||||
return "iot"
|
|
||||||
|
|
||||||
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
|
|
||||||
found: set[str] = set()
|
found: set[str] = set()
|
||||||
for p in open_ports:
|
for p in open_ports:
|
||||||
port = p["port"]
|
port = p["port"]
|
||||||
@@ -223,10 +126,10 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
|
|||||||
found.add(sig["suggested_node_type"])
|
found.add(sig["suggested_node_type"])
|
||||||
if port in _PORT_TYPE_HINTS:
|
if port in _PORT_TYPE_HINTS:
|
||||||
found.add(_PORT_TYPE_HINTS[port])
|
found.add(_PORT_TYPE_HINTS[port])
|
||||||
|
# MAC OUI is a lower-priority hint — only used if ports give no better answer
|
||||||
|
mac_type = suggest_type_from_mac(mac)
|
||||||
if mac_type:
|
if mac_type:
|
||||||
found.add(mac_type)
|
found.add(mac_type)
|
||||||
|
|
||||||
for t in priority:
|
for t in priority:
|
||||||
if t in found:
|
if t in found:
|
||||||
return t
|
return t
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
"""HTTP probe: GET a discovered port and extract identifying signals.
|
|
||||||
|
|
||||||
Used by the optional deep-scan mode to confirm what service sits behind an
|
|
||||||
open port, regardless of port number. Returns the page <title> plus a small
|
|
||||||
set of identifying response headers, which fingerprint.match_service() then
|
|
||||||
matches against signature http_regex fields.
|
|
||||||
"""
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Headers that commonly carry the application name.
|
|
||||||
_SIGNAL_HEADERS = ("Server", "X-Powered-By")
|
|
||||||
# Cap how much body we read when hunting for <title> — avoids large downloads.
|
|
||||||
_MAX_BODY_BYTES = 64 * 1024
|
|
||||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
|
||||||
_PROBE_TIMEOUT = 3.0
|
|
||||||
# Ports we never bother probing over HTTP (not web services).
|
|
||||||
_NON_HTTP_PORTS = frozenset({22, 21, 23, 25, 53, 110, 143, 161, 162, 179, 445, 3306, 5432, 6379})
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(body: str) -> str | None:
|
|
||||||
m = _TITLE_RE.search(body)
|
|
||||||
if not m:
|
|
||||||
return None
|
|
||||||
title = re.sub(r"\s+", " ", m.group(1)).strip()
|
|
||||||
return title or None
|
|
||||||
|
|
||||||
|
|
||||||
async def _probe_scheme(client: httpx.AsyncClient, url: str) -> dict[str, Any] | None:
|
|
||||||
try:
|
|
||||||
resp = await client.get(url, follow_redirects=True)
|
|
||||||
except (httpx.HTTPError, OSError):
|
|
||||||
return None
|
|
||||||
headers = {h: resp.headers[h] for h in _SIGNAL_HEADERS if h in resp.headers}
|
|
||||||
body = resp.text[:_MAX_BODY_BYTES] if resp.text else ""
|
|
||||||
title = _extract_title(body)
|
|
||||||
if not title and not headers:
|
|
||||||
return None
|
|
||||||
return {"title": title, "headers": headers}
|
|
||||||
|
|
||||||
|
|
||||||
async def probe_port(
|
|
||||||
ip: str, port: int, verify_tls: bool = False
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
GET https:// then http:// for a port and return {title, headers} or None.
|
|
||||||
|
|
||||||
None means the port did not answer HTTP or yielded no usable signal.
|
|
||||||
"""
|
|
||||||
if port in _NON_HTTP_PORTS:
|
|
||||||
return None
|
|
||||||
async with httpx.AsyncClient(verify=verify_tls, timeout=_PROBE_TIMEOUT) as client:
|
|
||||||
for scheme in ("https", "http"):
|
|
||||||
result = await _probe_scheme(client, f"{scheme}://{ip}:{port}/")
|
|
||||||
if result is not None:
|
|
||||||
return result
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def probe_open_ports(
|
|
||||||
ip: str,
|
|
||||||
open_ports: list[dict[str, Any]],
|
|
||||||
verify_tls: bool = False,
|
|
||||||
concurrency: int = 50,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Probe every open port for HTTP signals (option 2: probe all, match after).
|
|
||||||
|
|
||||||
Returns the same port dicts, each enriched with an http_signals key
|
|
||||||
(None when the port gave no HTTP signal).
|
|
||||||
"""
|
|
||||||
sem = asyncio.Semaphore(concurrency)
|
|
||||||
|
|
||||||
async def _one(p: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
async with sem:
|
|
||||||
signals = await probe_port(ip, p["port"], verify_tls)
|
|
||||||
return {**p, "http_signals": signals}
|
|
||||||
|
|
||||||
return await asyncio.gather(*(_one(p) for p in open_ports))
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
"""MAC-address normalization, shared by the scan + Proxmox persist paths.
|
|
||||||
|
|
||||||
Different discovery sources emit MACs in different casing/separators (ARP is
|
|
||||||
lowercase ``bc:24:11:..``, Proxmox config is often uppercase ``BC:24:11:..``).
|
|
||||||
Canonicalizing on write *and* on compare lets cross-source dedup match a device
|
|
||||||
by MAC with a plain ``==`` — the join key for merging an IP-scanned row with a
|
|
||||||
Proxmox-imported one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_mac(mac: str | None) -> str | None:
|
|
||||||
"""Canonical MAC: lowercase, ``-`` → ``:``, stripped. Blank/None → None."""
|
|
||||||
if not mac:
|
|
||||||
return None
|
|
||||||
normalized = mac.strip().lower().replace("-", ":")
|
|
||||||
return normalized or None
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""Shared MQTT helpers for the Zigbee and Z-Wave import services.
|
|
||||||
|
|
||||||
Holds the credential-safe error sanitizer, the TLS context builder, and a
|
|
||||||
generic request/response round-trip over MQTT used by gateway-style APIs
|
|
||||||
(publish a request topic, wait for a single response topic message).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import ssl
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import aiomqtt
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
aiomqtt = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
|
||||||
_RESPONSE_TIMEOUT = 300.0 # seconds to wait for a gateway response (large meshes are slow)
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_mqtt_error(exc: BaseException) -> str:
|
|
||||||
"""Return a generic, credential-free message for an MQTT error.
|
|
||||||
|
|
||||||
The raw aiomqtt/paho error string can include the broker URI with
|
|
||||||
embedded credentials (e.g. ``mqtt://user:pass@host``) or auth-related
|
|
||||||
detail that should not leak to API clients. Map known patterns to
|
|
||||||
coarse categories; default to a generic failure message. The original
|
|
||||||
exception is logged at WARNING level for operator debugging.
|
|
||||||
"""
|
|
||||||
logger.warning("MQTT error (sanitized for client): %r", exc)
|
|
||||||
raw = str(exc).lower()
|
|
||||||
if "not authoriz" in raw or "bad user" in raw or "bad username" in raw:
|
|
||||||
return "Authentication failed"
|
|
||||||
if "refused" in raw:
|
|
||||||
return "Connection refused by broker"
|
|
||||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
|
||||||
return "Broker hostname could not be resolved"
|
|
||||||
if "ssl" in raw or "tls" in raw or "certificate" in raw:
|
|
||||||
return "TLS handshake failed"
|
|
||||||
if "timed out" in raw or "timeout" in raw:
|
|
||||||
return "Connection to broker timed out"
|
|
||||||
return "MQTT connection failed"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tls_context(insecure: bool) -> ssl.SSLContext:
|
|
||||||
"""Build an SSL context for MQTT TLS. If insecure, skip verification."""
|
|
||||||
ctx = ssl.create_default_context()
|
|
||||||
if insecure:
|
|
||||||
logger.warning(
|
|
||||||
"MQTT TLS certificate verification is DISABLED — "
|
|
||||||
"use only with self-signed brokers on trusted networks."
|
|
||||||
)
|
|
||||||
ctx.check_hostname = False
|
|
||||||
ctx.verify_mode = ssl.CERT_NONE
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def request_response(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
request_topic: str,
|
|
||||||
response_topic: str,
|
|
||||||
request_payload: dict[str, Any],
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
response_timeout: float = _RESPONSE_TIMEOUT,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Publish ``request_payload`` to ``request_topic`` and return the first
|
|
||||||
JSON message received on ``response_topic`` as a dict.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImportError: if aiomqtt is not installed.
|
|
||||||
TimeoutError: if no response arrives in time.
|
|
||||||
ConnectionError: if the broker cannot be reached.
|
|
||||||
ValueError: if the response payload is not valid JSON / is empty.
|
|
||||||
"""
|
|
||||||
if aiomqtt is None: # pragma: no cover
|
|
||||||
raise ImportError(
|
|
||||||
"aiomqtt is required for MQTT import. "
|
|
||||||
"Install it with: pip install aiomqtt"
|
|
||||||
)
|
|
||||||
|
|
||||||
response_payload: dict[str, Any] = {}
|
|
||||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiomqtt.Client(
|
|
||||||
hostname=mqtt_host,
|
|
||||||
port=mqtt_port,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
timeout=_CONNECTION_TIMEOUT,
|
|
||||||
tls_context=tls_context,
|
|
||||||
) as client:
|
|
||||||
await client.subscribe(response_topic)
|
|
||||||
# Give the broker a brief window to register the subscription
|
|
||||||
# before we publish the request. Without this, brokers that
|
|
||||||
# race SUBACK with our PUBLISH may deliver the response before
|
|
||||||
# the subscription is active and we'd hang until timeout.
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
await client.publish(request_topic, json.dumps(request_payload))
|
|
||||||
|
|
||||||
async def _wait_for_response() -> None:
|
|
||||||
async for message in client.messages:
|
|
||||||
if str(message.topic) != response_topic:
|
|
||||||
continue
|
|
||||||
raw = message.payload
|
|
||||||
try:
|
|
||||||
payload_str = (
|
|
||||||
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
|
||||||
)
|
|
||||||
response_payload.update(json.loads(payload_str))
|
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
|
||||||
raise ValueError(f"Malformed MQTT response: {exc}") from exc
|
|
||||||
return
|
|
||||||
|
|
||||||
await asyncio.wait_for(_wait_for_response(), timeout=response_timeout)
|
|
||||||
|
|
||||||
except aiomqtt.MqttError as exc:
|
|
||||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
raise TimeoutError("Timed out waiting for MQTT response") from exc
|
|
||||||
|
|
||||||
if not response_payload:
|
|
||||||
raise ValueError("Empty MQTT response received")
|
|
||||||
|
|
||||||
return response_payload
|
|
||||||
|
|
||||||
|
|
||||||
async def test_connection(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Attempt a quick MQTT connection to verify broker reachability.
|
|
||||||
|
|
||||||
Returns True on success, raises ConnectionError/TimeoutError on failure.
|
|
||||||
"""
|
|
||||||
if aiomqtt is None: # pragma: no cover
|
|
||||||
raise ImportError("aiomqtt is required")
|
|
||||||
|
|
||||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiomqtt.Client(
|
|
||||||
hostname=mqtt_host,
|
|
||||||
port=mqtt_port,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
timeout=_CONNECTION_TIMEOUT,
|
|
||||||
tls_context=tls_context,
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
except aiomqtt.MqttError as exc:
|
|
||||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
raise TimeoutError("Connection to broker timed out") from exc
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
|
|
||||||
``design_id`` (i.e. the same device placed twice on the *same* canvas).
|
|
||||||
|
|
||||||
The same device legitimately appears on multiple canvases — placing a device on
|
|
||||||
another design creates a second :class:`Node` for that IEEE by design (see the
|
|
||||||
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
|
|
||||||
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
|
|
||||||
design are corrupt, and only those are collapsed here.
|
|
||||||
|
|
||||||
This module provides an idempotent, **loss-free** repair: per ``(ieee,
|
|
||||||
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
|
|
||||||
extras' data into it (properties, missing scalar fields, services), re-points
|
|
||||||
every edge and ``parent_id`` reference onto the canonical node, then deletes the
|
|
||||||
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
|
|
||||||
dangling or redundant links remain.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.models import Edge, Node
|
|
||||||
from app.services.zigbee_service import merge_zigbee_properties
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _ip_tokens(ip: str | None) -> list[str]:
|
|
||||||
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
|
|
||||||
|
|
||||||
The canvas stores several addresses in one comma-separated string once a
|
|
||||||
user adds e.g. an IPv6 address, so identity matching must compare per token.
|
|
||||||
"""
|
|
||||||
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
|
|
||||||
|
|
||||||
|
|
||||||
async def find_duplicate_node(
|
|
||||||
db: AsyncSession,
|
|
||||||
design_id: str | None,
|
|
||||||
ip: str | None,
|
|
||||||
mac: str | None,
|
|
||||||
ieee: str | None = None,
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
|
|
||||||
already sits on ``design_id``, else ``None``.
|
|
||||||
|
|
||||||
Scoped to a single design on purpose: the same device may legitimately
|
|
||||||
appear on several canvases (one :class:`Node` per design). Only a second
|
|
||||||
node for the same ieee/ip/mac on the *same* design is a duplicate — which
|
|
||||||
the create/approve endpoints turn into a 409 so the UI can offer "go to
|
|
||||||
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
|
|
||||||
plain IP/ARP hosts.
|
|
||||||
|
|
||||||
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
|
|
||||||
duplicates; this guard prevents new ones unless the user forces them.)
|
|
||||||
"""
|
|
||||||
ip_toks = _ip_tokens(ip)
|
|
||||||
conds = []
|
|
||||||
if ieee:
|
|
||||||
conds.append(Node.ieee_address == ieee)
|
|
||||||
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
|
|
||||||
# before the IPv4), so narrow with a substring match then confirm per-token
|
|
||||||
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
|
|
||||||
for tok in ip_toks:
|
|
||||||
conds.append(Node.ip.contains(tok))
|
|
||||||
if mac:
|
|
||||||
conds.append(Node.mac == mac)
|
|
||||||
if not conds:
|
|
||||||
return None
|
|
||||||
candidates = (
|
|
||||||
await db.execute(
|
|
||||||
select(Node).where(Node.design_id == design_id, or_(*conds))
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
|
|
||||||
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
|
|
||||||
def _matches(node: Node) -> tuple[str, str | None] | None:
|
|
||||||
if ieee and node.ieee_address == ieee:
|
|
||||||
return "ieee", node.ieee_address
|
|
||||||
node_toks = set(_ip_tokens(node.ip))
|
|
||||||
hit = next((t for t in ip_toks if t in node_toks), None)
|
|
||||||
if hit is not None:
|
|
||||||
return "ip", hit
|
|
||||||
if mac and node.mac == mac:
|
|
||||||
return "mac", mac
|
|
||||||
return None
|
|
||||||
|
|
||||||
existing = None
|
|
||||||
matched: tuple[str, str | None] | None = None
|
|
||||||
for node in candidates:
|
|
||||||
m = _matches(node)
|
|
||||||
if m is not None:
|
|
||||||
existing, matched = node, m
|
|
||||||
break
|
|
||||||
if existing is None or matched is None:
|
|
||||||
return None
|
|
||||||
match, value = matched
|
|
||||||
return {
|
|
||||||
"duplicate": True,
|
|
||||||
"existing_node_id": existing.id,
|
|
||||||
"existing_label": existing.label,
|
|
||||||
"match": match,
|
|
||||||
"value": value,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Scalar Node fields worth carrying over from a duplicate when the canonical
|
|
||||||
# node has no value. Positions, design, parent and identity fields are left on
|
|
||||||
# the canonical node untouched (that's the row the user actually placed).
|
|
||||||
_FILLABLE_FIELDS = (
|
|
||||||
"hostname",
|
|
||||||
"ip",
|
|
||||||
"mac",
|
|
||||||
"os",
|
|
||||||
"check_method",
|
|
||||||
"check_target",
|
|
||||||
"notes",
|
|
||||||
"cpu_count",
|
|
||||||
"cpu_model",
|
|
||||||
"ram_gb",
|
|
||||||
"disk_gb",
|
|
||||||
"custom_icon",
|
|
||||||
"last_seen",
|
|
||||||
"last_scan",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_services(
|
|
||||||
a: list[Any] | None, b: list[Any] | None
|
|
||||||
) -> list[Any]:
|
|
||||||
"""Union two service lists, de-duplicated, order-stable."""
|
|
||||||
out: list[Any] = list(a or [])
|
|
||||||
seen = {repr(s) for s in out}
|
|
||||||
for s in b or []:
|
|
||||||
if repr(s) not in seen:
|
|
||||||
out.append(s)
|
|
||||||
seen.add(repr(s))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
|
|
||||||
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
|
|
||||||
canonical.properties = merge_zigbee_properties(
|
|
||||||
canonical.properties, dup.properties or []
|
|
||||||
)
|
|
||||||
canonical.services = _merge_services(canonical.services, dup.services)
|
|
||||||
for field in _FILLABLE_FIELDS:
|
|
||||||
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
|
|
||||||
setattr(canonical, field, getattr(dup, field))
|
|
||||||
# Prefer a human label over a bare IEEE/hex fallback.
|
|
||||||
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
|
|
||||||
canonical.label = dup.label
|
|
||||||
|
|
||||||
|
|
||||||
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
|
|
||||||
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
|
|
||||||
|
|
||||||
Returns the number of nodes removed. Idempotent: a no-op when every
|
|
||||||
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
|
|
||||||
on *different* designs are left untouched (valid cross-canvas placement).
|
|
||||||
Does not commit — the caller owns the transaction.
|
|
||||||
"""
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(Node)
|
|
||||||
.where(Node.ieee_address.is_not(None))
|
|
||||||
.order_by(Node.ieee_address, Node.created_at, Node.id)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
|
|
||||||
groups: dict[tuple[str, str | None], list[Node]] = {}
|
|
||||||
for node in rows:
|
|
||||||
groups.setdefault((node.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
removed = 0
|
|
||||||
for (ieee, _design), nodes in groups.items():
|
|
||||||
if len(nodes) < 2:
|
|
||||||
continue
|
|
||||||
canonical, *dups = nodes # oldest first (ordered above)
|
|
||||||
dup_ids = {d.id for d in dups}
|
|
||||||
|
|
||||||
for dup in dups:
|
|
||||||
_merge_into_canonical(canonical, dup)
|
|
||||||
|
|
||||||
# Re-point edges + parents, then drop self-loops / duplicates.
|
|
||||||
edges = (
|
|
||||||
await db.execute(
|
|
||||||
select(Edge).where(
|
|
||||||
Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
for edge in edges:
|
|
||||||
if edge.source in dup_ids:
|
|
||||||
edge.source = canonical.id
|
|
||||||
if edge.target in dup_ids:
|
|
||||||
edge.target = canonical.id
|
|
||||||
|
|
||||||
# Re-point children whose parent was a duplicate.
|
|
||||||
children = (
|
|
||||||
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
|
|
||||||
).scalars().all()
|
|
||||||
for child in children:
|
|
||||||
child.parent_id = canonical.id
|
|
||||||
|
|
||||||
# Collapse self-loops and now-redundant parallel edges.
|
|
||||||
all_edges = (
|
|
||||||
await db.execute(
|
|
||||||
select(Edge).where(
|
|
||||||
(Edge.source == canonical.id) | (Edge.target == canonical.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
seen_pairs: set[tuple[str, str, str]] = set()
|
|
||||||
for edge in all_edges:
|
|
||||||
if edge.source == edge.target:
|
|
||||||
await db.delete(edge)
|
|
||||||
continue
|
|
||||||
key = (edge.source, edge.target, edge.type)
|
|
||||||
if key in seen_pairs:
|
|
||||||
await db.delete(edge)
|
|
||||||
continue
|
|
||||||
seen_pairs.add(key)
|
|
||||||
|
|
||||||
await db.flush()
|
|
||||||
for dup in dups:
|
|
||||||
await db.delete(dup)
|
|
||||||
removed += 1
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
|
|
||||||
ieee, len(dups), canonical.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if removed:
|
|
||||||
await db.flush()
|
|
||||||
return removed
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
"""Proxmox VE inventory service: fetch hosts + VMs + LXC via the PVE REST API.
|
|
||||||
|
|
||||||
Mirrors the Zigbee/Z-Wave import pipeline, but talks to the Proxmox VE REST API
|
|
||||||
(``/api2/json``) over HTTPS with an API token instead of MQTT. It returns plain
|
|
||||||
homelable node dicts + parent→child edge hints; DB persistence lives in the
|
|
||||||
route layer (``app.api.routes.proxmox``).
|
|
||||||
|
|
||||||
Auth uses a Proxmox **API token** (never a password):
|
|
||||||
``Authorization: PVEAPIToken=<token_id>=<secret>`` where ``token_id`` looks like
|
|
||||||
``user@realm!tokenname``. A read-only ``PVEAuditor`` role is all that is needed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.services.mac_utils import normalize_mac
|
|
||||||
from app.services.zigbee_service import merge_zigbee_properties
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Reuse the zigbee property-merge contract verbatim (same NodeProperty shape +
|
|
||||||
# visibility-preservation rules) for re-sync updates.
|
|
||||||
merge_proxmox_properties = merge_zigbee_properties
|
|
||||||
|
|
||||||
_CONNECT_TIMEOUT = 8.0
|
|
||||||
_READ_TIMEOUT = 20.0
|
|
||||||
_BYTES_PER_GB = 1024 ** 3
|
|
||||||
|
|
||||||
# net0 config line: "name=eth0,bridge=vmbr0,ip=192.168.1.5/24,gw=..."
|
|
||||||
_LXC_IP_RE = re.compile(r"(?:^|,)ip=([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?:/\d+)?")
|
|
||||||
# NIC MAC inside a net0 string — qemu "virtio=BC:24:11:..,bridge=.." or lxc
|
|
||||||
# "..,hwaddr=BC:24:11:..,..". A bare 6-octet MAC match works for both forms.
|
|
||||||
_NET_MAC_RE = re.compile(r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_proxmox_error(exc: BaseException) -> str:
|
|
||||||
"""Return a generic, credential-free message for a Proxmox/HTTP error.
|
|
||||||
|
|
||||||
Raw httpx errors can echo the request URL and, worse, an
|
|
||||||
``Authorization: PVEAPIToken=...=<secret>`` header in some stacks. Map known
|
|
||||||
patterns to coarse categories so the token never reaches an API client. The
|
|
||||||
original exception is logged at WARNING for operators.
|
|
||||||
"""
|
|
||||||
logger.warning("Proxmox error (sanitized for client): %r", exc)
|
|
||||||
if isinstance(exc, httpx.HTTPStatusError):
|
|
||||||
code = exc.response.status_code
|
|
||||||
if code in (401, 403):
|
|
||||||
return "Authentication failed — check the API token and its permissions"
|
|
||||||
if code == 404:
|
|
||||||
return "Proxmox API path not found — is this a Proxmox VE host?"
|
|
||||||
return f"Proxmox API returned HTTP {code}"
|
|
||||||
raw = str(exc).lower()
|
|
||||||
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
|
||||||
return "Proxmox host could not be resolved"
|
|
||||||
if "refused" in raw:
|
|
||||||
return "Connection refused by Proxmox host"
|
|
||||||
if "certificate" in raw or "ssl" in raw or "tls" in raw:
|
|
||||||
return "TLS verification failed — enable 'skip TLS verify' for self-signed certs"
|
|
||||||
if "timed out" in raw or "timeout" in raw:
|
|
||||||
return "Connection to Proxmox host timed out"
|
|
||||||
return "Proxmox connection failed"
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_header(token_id: str, token_secret: str) -> dict[str, str]:
|
|
||||||
return {"Authorization": f"PVEAPIToken={token_id}={token_secret}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _gb(value: Any) -> float | None:
|
|
||||||
"""Convert a byte count to GB (1 decimal). None/0 → None."""
|
|
||||||
try:
|
|
||||||
num = float(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if num <= 0:
|
|
||||||
return None
|
|
||||||
return round(num / _BYTES_PER_GB, 1)
|
|
||||||
|
|
||||||
|
|
||||||
def _int_or_none(value: Any) -> int | None:
|
|
||||||
try:
|
|
||||||
return int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_type_to_homelable(kind: str) -> str:
|
|
||||||
"""qemu → vm, lxc → lxc (both existing homelable node types)."""
|
|
||||||
return "vm" if kind == "qemu" else "lxc"
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_qemu_ip(agent_payload: dict[str, Any] | None) -> str | None:
|
|
||||||
"""Pull the first non-loopback IPv4 from a qemu guest-agent interfaces reply."""
|
|
||||||
if not agent_payload:
|
|
||||||
return None
|
|
||||||
result = agent_payload.get("result")
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return None
|
|
||||||
for iface in result:
|
|
||||||
if not isinstance(iface, dict):
|
|
||||||
continue
|
|
||||||
for addr in iface.get("ip-addresses") or []:
|
|
||||||
if not isinstance(addr, dict):
|
|
||||||
continue
|
|
||||||
if addr.get("ip-address-type") != "ipv4":
|
|
||||||
continue
|
|
||||||
ip = addr.get("ip-address")
|
|
||||||
if isinstance(ip, str) and ip and not ip.startswith("127."):
|
|
||||||
return ip
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_lxc_ip(config_payload: dict[str, Any] | None) -> str | None:
|
|
||||||
"""Parse a static IPv4 from an LXC ``net0`` config string (skip dhcp)."""
|
|
||||||
if not config_payload:
|
|
||||||
return None
|
|
||||||
net0 = config_payload.get("net0")
|
|
||||||
if not isinstance(net0, str):
|
|
||||||
return None
|
|
||||||
match = _LXC_IP_RE.search(net0)
|
|
||||||
return match.group(1) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_net_mac(config_payload: dict[str, Any] | None) -> str | None:
|
|
||||||
"""Parse the NIC MAC from a qemu/lxc ``net0`` config string (normalized).
|
|
||||||
|
|
||||||
Works agent-free for both guest kinds and for stopped guests — the sole
|
|
||||||
identity we can reliably cross-match against an ARP-scanned device.
|
|
||||||
"""
|
|
||||||
if not config_payload:
|
|
||||||
return None
|
|
||||||
net0 = config_payload.get("net0")
|
|
||||||
if not isinstance(net0, str):
|
|
||||||
return None
|
|
||||||
match = _NET_MAC_RE.search(net0)
|
|
||||||
return normalize_mac(match.group(1)) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def _host_node(raw: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""Build a homelable ``proxmox`` host node from a ``/nodes`` entry."""
|
|
||||||
name = raw.get("node")
|
|
||||||
if not name:
|
|
||||||
return None
|
|
||||||
ieee = f"pve-node-{name}"
|
|
||||||
return {
|
|
||||||
"id": ieee,
|
|
||||||
"label": name,
|
|
||||||
"type": "proxmox",
|
|
||||||
"ieee_address": ieee,
|
|
||||||
"hostname": name,
|
|
||||||
"ip": raw.get("ip") or None,
|
|
||||||
"status": "online" if raw.get("status") == "online" else "offline",
|
|
||||||
"cpu_count": _int_or_none(raw.get("maxcpu")),
|
|
||||||
"ram_gb": _gb(raw.get("maxmem")),
|
|
||||||
"disk_gb": _gb(raw.get("maxdisk")),
|
|
||||||
"vendor": "Proxmox VE",
|
|
||||||
"model": None,
|
|
||||||
"parent_ieee": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_node(
|
|
||||||
raw: dict[str, Any], host_name: str, kind: str, ip: str | None, mac: str | None = None
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Build a homelable vm/lxc node from a ``/qemu`` or ``/lxc`` list entry."""
|
|
||||||
vmid = raw.get("vmid")
|
|
||||||
if vmid is None:
|
|
||||||
return None
|
|
||||||
ieee = f"pve-{host_name}-{vmid}"
|
|
||||||
node_type = _guest_type_to_homelable(kind)
|
|
||||||
name = raw.get("name") or f"{node_type}-{vmid}"
|
|
||||||
return {
|
|
||||||
"id": ieee,
|
|
||||||
"label": name,
|
|
||||||
"type": node_type,
|
|
||||||
"ieee_address": ieee,
|
|
||||||
"hostname": name,
|
|
||||||
"ip": ip,
|
|
||||||
"mac": mac,
|
|
||||||
"status": "online" if raw.get("status") == "running" else "offline",
|
|
||||||
"cpu_count": _int_or_none(raw.get("maxcpu") or raw.get("cpus")),
|
|
||||||
"ram_gb": _gb(raw.get("maxmem")),
|
|
||||||
"disk_gb": _gb(raw.get("maxdisk")),
|
|
||||||
"vendor": "Proxmox VE",
|
|
||||||
"model": kind.upper(),
|
|
||||||
"vmid": vmid,
|
|
||||||
"parent_ieee": f"pve-node-{host_name}",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_proxmox_properties(node: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
"""Build a NodeProperty list for a Proxmox device (specs + identity).
|
|
||||||
|
|
||||||
Icons match the existing hardware-property convention (``Cpu`` /
|
|
||||||
``MemoryStick`` / ``HardDrive``). All rows default ``visible=False`` — the
|
|
||||||
user opts in from the right panel, same as the mesh importers."""
|
|
||||||
props: list[dict[str, Any]] = []
|
|
||||||
vmid = node.get("vmid")
|
|
||||||
if vmid is not None:
|
|
||||||
props.append({"key": "VMID", "value": str(vmid), "icon": None, "visible": False})
|
|
||||||
if node.get("model"):
|
|
||||||
props.append({"key": "Kind", "value": node["model"], "icon": None, "visible": False})
|
|
||||||
if node.get("cpu_count") is not None:
|
|
||||||
props.append({"key": "CPU Cores", "value": str(node["cpu_count"]), "icon": "Cpu", "visible": False})
|
|
||||||
if node.get("ram_gb") is not None:
|
|
||||||
props.append({"key": "RAM", "value": f"{node['ram_gb']} GB", "icon": "MemoryStick", "visible": False})
|
|
||||||
if node.get("disk_gb") is not None:
|
|
||||||
props.append({"key": "Disk", "value": f"{node['disk_gb']} GB", "icon": "HardDrive", "visible": False})
|
|
||||||
props.append({"key": "Source", "value": "Proxmox VE", "icon": None, "visible": False})
|
|
||||||
return props
|
|
||||||
|
|
||||||
|
|
||||||
def build_proxmox_cluster_links(nodes: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
|
||||||
"""Chain host nodes (``type == 'proxmox'``) into cluster links.
|
|
||||||
|
|
||||||
Hosts from one import belong to the same cluster, so they are linked
|
|
||||||
host↔host (rendered as ``cluster`` edges via left/right handles, distinct
|
|
||||||
from the vertical host→guest ``virtual`` edges). Returns consecutive
|
|
||||||
``(source_ieee, target_ieee)`` pairs, or ``[]`` for a single host. Mirrors
|
|
||||||
the frontend ``buildProxmoxClusterEdges``.
|
|
||||||
"""
|
|
||||||
hosts = [n["ieee_address"] for n in nodes if n.get("type") == "proxmox" and n.get("ieee_address")]
|
|
||||||
if len(hosts) < 2:
|
|
||||||
return []
|
|
||||||
return [(hosts[i], hosts[i + 1]) for i in range(len(hosts) - 1)]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_inventory(
|
|
||||||
hosts_raw: list[dict[str, Any]],
|
|
||||||
guests_by_host: dict[str, list[dict[str, Any]]],
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Assemble (nodes, edges) from fetched host + guest data.
|
|
||||||
|
|
||||||
``guests_by_host`` maps host name → list of already-normalized guest node
|
|
||||||
dicts. Edges are one host→guest link per guest (materialized as canvas
|
|
||||||
edges on approval, mirroring the zigbee/zwave link mechanism).
|
|
||||||
"""
|
|
||||||
nodes: list[dict[str, Any]] = []
|
|
||||||
edges: list[dict[str, Any]] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
|
|
||||||
for raw in hosts_raw:
|
|
||||||
host = _host_node(raw)
|
|
||||||
if host is None or host["id"] in seen:
|
|
||||||
continue
|
|
||||||
seen.add(host["id"])
|
|
||||||
nodes.append(host)
|
|
||||||
|
|
||||||
for host_name, guests in guests_by_host.items():
|
|
||||||
host_ieee = f"pve-node-{host_name}"
|
|
||||||
for guest in guests:
|
|
||||||
if guest["id"] in seen:
|
|
||||||
continue
|
|
||||||
seen.add(guest["id"])
|
|
||||||
nodes.append(guest)
|
|
||||||
edges.append({"source": host_ieee, "target": guest["id"]})
|
|
||||||
|
|
||||||
return nodes, edges
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_json(client: httpx.AsyncClient, path: str) -> Any:
|
|
||||||
resp = await client.get(path)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json().get("data")
|
|
||||||
|
|
||||||
|
|
||||||
async def _token_has_permissions(client: httpx.AsyncClient) -> bool:
|
|
||||||
"""True if the API token holds *any* ACL.
|
|
||||||
|
|
||||||
Proxmox list endpoints (``/qemu``, ``/lxc``) silently return an empty
|
|
||||||
``200`` when the token lacks ``VM.Audit`` — indistinguishable from a host
|
|
||||||
that genuinely has no guests. ``GET /access/permissions`` returns ``{}`` for
|
|
||||||
a token with no ACL at all, which is the common misconfiguration (a
|
|
||||||
privilege-separated token created without its own permission). Best-effort:
|
|
||||||
on any error assume permissions exist so we never block a valid import.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
perms = await _get_json(client, "/access/permissions")
|
|
||||||
except httpx.HTTPError:
|
|
||||||
return True
|
|
||||||
return bool(perms) if isinstance(perms, dict) else True
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_proxmox_inventory(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token_id: str,
|
|
||||||
token_secret: str,
|
|
||||||
verify_tls: bool = True,
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Fetch hosts + VMs + LXC from a Proxmox VE host, return (nodes, edges).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ConnectionError: transport/DNS/TLS failures (sanitized message).
|
|
||||||
ValueError: malformed API response.
|
|
||||||
"""
|
|
||||||
base_url = f"https://{host}:{port}/api2/json"
|
|
||||||
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
|
||||||
guests_by_host: dict[str, list[dict[str, Any]]] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
base_url=base_url,
|
|
||||||
headers=_auth_header(token_id, token_secret),
|
|
||||||
verify=verify_tls,
|
|
||||||
timeout=timeout,
|
|
||||||
) as client:
|
|
||||||
hosts_raw = await _get_json(client, "/nodes")
|
|
||||||
if not isinstance(hosts_raw, list):
|
|
||||||
raise ValueError("Malformed /nodes response")
|
|
||||||
|
|
||||||
for host_entry in hosts_raw:
|
|
||||||
name = host_entry.get("node")
|
|
||||||
if not name or host_entry.get("status") != "online":
|
|
||||||
# Offline nodes can't be queried for guests; still shown as host.
|
|
||||||
continue
|
|
||||||
guests_by_host[name] = await _fetch_host_guests(client, name)
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
|
||||||
|
|
||||||
return _parse_inventory(hosts_raw, guests_by_host)
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_host_guests(
|
|
||||||
client: httpx.AsyncClient, host_name: str
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Fetch qemu + lxc guests for one host, resolving guest IPs best-effort."""
|
|
||||||
guests: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for kind in ("qemu", "lxc"):
|
|
||||||
try:
|
|
||||||
entries = await _get_json(client, f"/nodes/{host_name}/{kind}")
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
logger.warning("Proxmox %s list failed for %s: %s", kind, host_name, exc)
|
|
||||||
continue
|
|
||||||
if not isinstance(entries, list):
|
|
||||||
continue
|
|
||||||
for raw in entries:
|
|
||||||
ip, mac = await _resolve_guest_net(client, host_name, kind, raw)
|
|
||||||
node = _guest_node(raw, host_name, kind, ip, mac)
|
|
||||||
if node:
|
|
||||||
guests.append(node)
|
|
||||||
|
|
||||||
return guests
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_guest_net(
|
|
||||||
client: httpx.AsyncClient, host_name: str, kind: str, raw: dict[str, Any]
|
|
||||||
) -> tuple[str | None, str | None]:
|
|
||||||
"""Best-effort (ip, mac) for a guest. Never raises.
|
|
||||||
|
|
||||||
- MAC comes from the guest ``/config`` net0 line for both kinds (agent-free,
|
|
||||||
works for stopped guests) — the cross-source dedup key.
|
|
||||||
- IP: qemu → guest agent (running only); lxc → static net0 config.
|
|
||||||
Partial results are returned even if a later call fails (e.g. config MAC is
|
|
||||||
kept when the qemu agent call errors).
|
|
||||||
"""
|
|
||||||
vmid = raw.get("vmid")
|
|
||||||
if vmid is None:
|
|
||||||
return None, None
|
|
||||||
ip: str | None = None
|
|
||||||
mac: str | None = None
|
|
||||||
try:
|
|
||||||
if kind == "qemu":
|
|
||||||
config = await _get_json(client, f"/nodes/{host_name}/qemu/{vmid}/config")
|
|
||||||
mac = _extract_net_mac(config)
|
|
||||||
if raw.get("status") == "running":
|
|
||||||
data = await _get_json(
|
|
||||||
client, f"/nodes/{host_name}/qemu/{vmid}/agent/network-get-interfaces"
|
|
||||||
)
|
|
||||||
ip = _extract_qemu_ip(data)
|
|
||||||
else:
|
|
||||||
config = await _get_json(client, f"/nodes/{host_name}/lxc/{vmid}/config")
|
|
||||||
ip = _extract_lxc_ip(config)
|
|
||||||
mac = _extract_net_mac(config)
|
|
||||||
except httpx.HTTPError:
|
|
||||||
# Guest agent not installed / container stopped / no perms. Keep whatever
|
|
||||||
# was resolved before the failure.
|
|
||||||
pass
|
|
||||||
return ip, mac
|
|
||||||
|
|
||||||
|
|
||||||
async def test_proxmox_connection(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
token_id: str,
|
|
||||||
token_secret: str,
|
|
||||||
verify_tls: bool = True,
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Quick reachability + auth check via ``GET /version``.
|
|
||||||
|
|
||||||
Returns (connected, message). Never raises credentials outward.
|
|
||||||
"""
|
|
||||||
base_url = f"https://{host}:{port}/api2/json"
|
|
||||||
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
base_url=base_url,
|
|
||||||
headers=_auth_header(token_id, token_secret),
|
|
||||||
verify=verify_tls,
|
|
||||||
timeout=timeout,
|
|
||||||
) as client:
|
|
||||||
data = await _get_json(client, "/version")
|
|
||||||
has_perms = await _token_has_permissions(client)
|
|
||||||
version = (data or {}).get("version", "?") if isinstance(data, dict) else "?"
|
|
||||||
message = f"Connected to Proxmox VE {version}"
|
|
||||||
if not has_perms:
|
|
||||||
message += (
|
|
||||||
" — warning: this API token has no permissions, so VMs and LXC "
|
|
||||||
"will not be visible. Assign the PVEAuditor role at path '/' to the "
|
|
||||||
"token in Proxmox (Datacenter → Permissions → API Token Permission)."
|
|
||||||
)
|
|
||||||
return True, message
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
return False, _sanitize_proxmox_error(exc)
|
|
||||||
except Exception as exc: # noqa: BLE001 — surface a safe message, log the rest
|
|
||||||
logger.exception("Unexpected error during Proxmox connection test")
|
|
||||||
return False, _sanitize_proxmox_error(exc)
|
|
||||||
+78
-591
@@ -1,84 +1,17 @@
|
|||||||
"""Network scanner: ARP sweep + nmap service detection + mDNS discovery."""
|
"""Network scanner: ARP sweep + nmap service detection."""
|
||||||
import asyncio
|
import asyncio
|
||||||
import ipaddress
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.db.models import PendingDevice, ScanRun
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
|
||||||
from app.services.discovery_sources import add_source
|
|
||||||
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
from app.services.fingerprint import fingerprint_ports, suggest_node_type
|
||||||
from app.services.http_probe import probe_open_ports
|
|
||||||
from app.services.mac_utils import normalize_mac
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Run IDs that have been requested to cancel (thread-safe via lock)
|
|
||||||
_cancelled_runs: set[str] = set()
|
|
||||||
_cancelled_lock = threading.Lock()
|
|
||||||
|
|
||||||
# Port list for service detection (Phase 2)
|
|
||||||
_EXTRA_PORTS = (
|
|
||||||
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
|
||||||
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
|
||||||
"3389,4711,4915,5000,5001,5432,5601,5683,5684,5900,5984,"
|
|
||||||
"6052,6379,6432,6443,6767,6789,6800,7878,8000,8006,8080,"
|
|
||||||
"8081,8086,8088,8090,8096,8112,8123,8200,8291,8428,8443,"
|
|
||||||
"8554,8686,8789,8843,8880,8883,8971,8989,9000,9001,9090,"
|
|
||||||
"9091,9092,9093,9100,9117,9200,9300,9411,9443,9696,10051,"
|
|
||||||
"16686,34567,37777,51413,64738"
|
|
||||||
)
|
|
||||||
|
|
||||||
# nmap -p accepts "N" or "N-M"; user ranges are validated against this.
|
|
||||||
_PORT_RANGE_RE = re.compile(r"^\d{1,5}(-\d{1,5})?$")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeepScanOptions:
|
|
||||||
"""Per-scan deep-scan settings (None/empty → standard scan, today's behaviour)."""
|
|
||||||
|
|
||||||
http_ranges: list[str] = field(default_factory=list)
|
|
||||||
http_probe_enabled: bool = False
|
|
||||||
verify_tls: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_port_range(spec: str) -> bool:
|
|
||||||
if not _PORT_RANGE_RE.match(spec):
|
|
||||||
return False
|
|
||||||
parts = [int(p) for p in spec.split("-")]
|
|
||||||
if any(p < 1 or p > 65535 for p in parts):
|
|
||||||
return False
|
|
||||||
return len(parts) == 1 or parts[0] <= parts[1]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_port_spec(http_ranges: list[str] | None) -> str:
|
|
||||||
"""Combine the default port list with validated user ranges for nmap -p."""
|
|
||||||
if not http_ranges:
|
|
||||||
return _EXTRA_PORTS
|
|
||||||
extra = [r.strip() for r in http_ranges if _valid_port_range(r.strip())]
|
|
||||||
if not extra:
|
|
||||||
return _EXTRA_PORTS
|
|
||||||
return _EXTRA_PORTS + "," + ",".join(extra)
|
|
||||||
|
|
||||||
_MDNS_SERVICE_TYPES = [
|
|
||||||
"_http._tcp.local.",
|
|
||||||
"_shelly._tcp.local.",
|
|
||||||
"_esphomelib._tcp.local.",
|
|
||||||
"_hap._tcp.local.", # HomeKit Accessory Protocol
|
|
||||||
"_mqtt._tcp.local.",
|
|
||||||
"_device-info._tcp.local.",
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import nmap
|
import nmap
|
||||||
_NMAP_AVAILABLE = True
|
_NMAP_AVAILABLE = True
|
||||||
@@ -86,24 +19,50 @@ except ImportError:
|
|||||||
_NMAP_AVAILABLE = False
|
_NMAP_AVAILABLE = False
|
||||||
logger.warning("python-nmap not available — scanner will run in mock mode")
|
logger.warning("python-nmap not available — scanner will run in mock mode")
|
||||||
|
|
||||||
|
|
||||||
|
def _nmap_scan(target: str) -> list[dict[str, Any]]:
|
||||||
|
"""Run nmap -sV --open on target, return list of host dicts."""
|
||||||
|
if not _NMAP_AVAILABLE:
|
||||||
|
return _mock_scan(target)
|
||||||
|
|
||||||
|
nm = nmap.PortScanner()
|
||||||
try:
|
try:
|
||||||
from zeroconf import ServiceStateChange
|
# Home lab port range: standard top-1000 + common self-hosted service ports
|
||||||
from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf
|
extra_ports = (
|
||||||
_ZEROCONF_AVAILABLE = True
|
"80,443,22,21,23,25,53,110,143,161,162,179,389,445,548,"
|
||||||
except ImportError:
|
"554,636,873,1883,1880,1935,2020,2375,2376,3000,3001,3306,"
|
||||||
_ZEROCONF_AVAILABLE = False
|
"3389,4711,5000,5001,5432,5601,5900,5984,6052,6379,6432,6443,"
|
||||||
logger.warning("zeroconf not available — mDNS discovery disabled")
|
"6767,6789,6800,7878,8000,8006,8080,8081,8086,8088,8090,8096,"
|
||||||
|
"8112,8123,8200,8291,8428,8443,8554,8686,8789,8843,8880,8883,"
|
||||||
|
"8971,8989,9000,9001,9090,9091,9092,9093,9100,9117,9200,9300,"
|
||||||
|
"9411,9443,9696,10051,16686,34567,37777,51413,64738"
|
||||||
|
)
|
||||||
|
nm.scan(hosts=target, arguments=f"-sV --open -T4 --host-timeout 120s -p {extra_ports}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("nmap scan failed: %s", exc)
|
||||||
|
raise RuntimeError(str(exc)) from exc
|
||||||
|
|
||||||
|
hosts = []
|
||||||
def request_cancel(run_id: str) -> None:
|
for host in nm.all_hosts():
|
||||||
"""Signal a running scan to stop early."""
|
if nm[host].state() != "up":
|
||||||
with _cancelled_lock:
|
continue
|
||||||
_cancelled_runs.add(run_id)
|
open_ports = []
|
||||||
|
for proto in nm[host].all_protocols():
|
||||||
|
for port, info in nm[host][proto].items():
|
||||||
def _is_cancelled(run_id: str) -> bool:
|
if info["state"] == "open":
|
||||||
with _cancelled_lock:
|
open_ports.append({
|
||||||
return run_id in _cancelled_runs
|
"port": port,
|
||||||
|
"protocol": proto,
|
||||||
|
"banner": info.get("product", "") + " " + info.get("version", ""),
|
||||||
|
})
|
||||||
|
hosts.append({
|
||||||
|
"ip": host,
|
||||||
|
"hostname": _resolve_hostname(host),
|
||||||
|
"mac": nm[host].get("addresses", {}).get("mac"),
|
||||||
|
"os": _extract_os(nm, host),
|
||||||
|
"open_ports": open_ports,
|
||||||
|
})
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
def _resolve_hostname(ip: str) -> str | None:
|
def _resolve_hostname(ip: str) -> str | None:
|
||||||
@@ -123,336 +82,6 @@ def _extract_os(nm: object, host: str) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _arp_table_hosts(network: str) -> dict[str, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Read the OS ARP cache for recently-seen hosts in the target network.
|
|
||||||
Works without root on both Linux (/proc/net/arp) and macOS (arp -a).
|
|
||||||
Supplements nmap discovery — catches IoT and devices with all ports filtered.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
net = ipaddress.ip_network(network, strict=False)
|
|
||||||
found: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
# Linux: parse /proc/net/arp — present on any Linux kernel (including Docker)
|
|
||||||
proc_arp = "/proc/net/arp"
|
|
||||||
try:
|
|
||||||
with open(proc_arp) as f:
|
|
||||||
for line in f.readlines()[1:]: # skip header row
|
|
||||||
parts = line.split()
|
|
||||||
if len(parts) >= 4:
|
|
||||||
ip, mac = parts[0], parts[3]
|
|
||||||
if mac == "00:00:00:00:00:00":
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if ipaddress.ip_address(ip) in net:
|
|
||||||
found[ip] = {
|
|
||||||
"ip": ip, "mac": mac,
|
|
||||||
"hostname": _resolve_hostname(ip),
|
|
||||||
"os": None, "open_ports": [],
|
|
||||||
}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# /proc/net/arp opened successfully — return whatever we found (may be empty)
|
|
||||||
# Don't fall through to `arp -a` since we're on Linux
|
|
||||||
return found
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass # Not Linux — fall through to macOS `arp -a`
|
|
||||||
|
|
||||||
# macOS: parse `arp -a` output
|
|
||||||
result = subprocess.run(["arp", "-a"], capture_output=True, text=True, timeout=5)
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
m = re.search(r"\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)", line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
ip, mac = m.group(1), m.group(2)
|
|
||||||
if mac in ("(incomplete)", "ff:ff:ff:ff:ff:ff"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
if ipaddress.ip_address(ip) in net:
|
|
||||||
found[ip] = {"ip": ip, "mac": mac, "hostname": _resolve_hostname(ip), "os": None, "open_ports": []}
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return found
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("[Phase 1] ARP cache lookup failed: %s", exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
async def _ping_sweep(target: str, run_id: str | None = None) -> dict[str, dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Phase 1: Concurrent ICMP ping sweep + ARP cache.
|
|
||||||
Pings all IPs in the CIDR in parallel (up to 50 at once, 1s timeout each).
|
|
||||||
Supplements with the OS ARP cache to catch devices that block ICMP.
|
|
||||||
Works in Docker with CAP_NET_RAW — no nmap, no false positives.
|
|
||||||
"""
|
|
||||||
net = ipaddress.ip_network(target, strict=False)
|
|
||||||
all_ips = [str(ip) for ip in net.hosts()]
|
|
||||||
logger.info("[Phase 1] Pinging %d hosts in %s ...", len(all_ips), target)
|
|
||||||
|
|
||||||
sem = asyncio.Semaphore(50)
|
|
||||||
|
|
||||||
async def _ping(ip: str) -> str | None:
|
|
||||||
async with sem:
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
"ping", "-c", "1", "-W", "1", ip,
|
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
|
||||||
)
|
|
||||||
await proc.wait()
|
|
||||||
return ip if proc.returncode == 0 else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ping_results = await asyncio.gather(*[_ping(ip) for ip in all_ips])
|
|
||||||
alive_ips: set[str] = {ip for ip in ping_results if ip is not None}
|
|
||||||
logger.info("[Phase 1] %d/%d hosts responded to ping", len(alive_ips), len(all_ips))
|
|
||||||
|
|
||||||
# Cancelled during the sweep — bail before the (potentially long) Phase 2
|
|
||||||
# port scan. Returning empty makes _nmap_scan skip nmap entirely.
|
|
||||||
if run_id is not None and _is_cancelled(run_id):
|
|
||||||
logger.info("[Phase 1] %s — scan cancelled, skipping hostname/ARP enrichment", target)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
# ARP cache: catch devices that block ICMP but were recently active,
|
|
||||||
# and enrich ping-alive hosts with their MAC addresses.
|
|
||||||
arp_cache = await asyncio.to_thread(_arp_table_hosts, target)
|
|
||||||
|
|
||||||
alive: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
for ip in alive_ips:
|
|
||||||
mac = arp_cache.get(ip, {}).get("mac")
|
|
||||||
hostname = await asyncio.to_thread(_resolve_hostname, ip)
|
|
||||||
logger.info("[Phase 1] %s mac=%s hostname=%s (ping)", ip, mac or "n/a", hostname or "n/a")
|
|
||||||
alive[ip] = {"ip": ip, "mac": mac, "hostname": hostname, "os": None, "open_ports": []}
|
|
||||||
|
|
||||||
for ip, host in arp_cache.items():
|
|
||||||
if ip not in alive:
|
|
||||||
logger.info(
|
|
||||||
"[Phase 1] %s mac=%s hostname=%s (ARP cache only)",
|
|
||||||
ip, host.get("mac") or "n/a", host.get("hostname") or "n/a",
|
|
||||||
)
|
|
||||||
alive[ip] = host
|
|
||||||
|
|
||||||
return alive
|
|
||||||
|
|
||||||
|
|
||||||
def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Phase 2 — single-IP port scan with service detection.
|
|
||||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
|
||||||
|
|
||||||
Two decoupled nmap passes (issue #277):
|
|
||||||
Pass A — port discovery only (``--open``, no ``-sV``). Fast and reliable;
|
|
||||||
its open ports are authoritative and are never discarded.
|
|
||||||
Pass B — version detection (``-sV``) scoped to the ports Pass A found,
|
|
||||||
bounded by ``--host-timeout``. Best-effort: if it times out or
|
|
||||||
fails (e.g. a TLS port stalls plaintext probes), the Pass A ports
|
|
||||||
are kept with empty banners instead of the whole host being lost.
|
|
||||||
"""
|
|
||||||
ip = host_dict["ip"]
|
|
||||||
logger.info("[Phase 2] Scanning %s ...", ip)
|
|
||||||
|
|
||||||
if not _NMAP_AVAILABLE:
|
|
||||||
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
|
|
||||||
# -sT without root but being explicit avoids edge cases.
|
|
||||||
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
|
||||||
|
|
||||||
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
|
||||||
discovery_args = f"{scan_type} --open -T4 -Pn -p {port_spec}"
|
|
||||||
logger.debug("[Phase 2] %s discovery args: %s", ip, discovery_args)
|
|
||||||
nm_disc = nmap.PortScanner()
|
|
||||||
try:
|
|
||||||
nm_disc.scan(hosts=ip, arguments=discovery_args)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("[Phase 2] nmap discovery FAILED for %s (%s: %s) — skipping port scan",
|
|
||||||
ip, type(exc).__name__, exc)
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
if ip not in nm_disc.all_hosts():
|
|
||||||
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
open_ports = []
|
|
||||||
for proto in nm_disc[ip].all_protocols():
|
|
||||||
for port, info in nm_disc[ip][proto].items():
|
|
||||||
if info["state"] == "open":
|
|
||||||
open_ports.append({"port": port, "protocol": proto, "banner": ""})
|
|
||||||
|
|
||||||
if not open_ports:
|
|
||||||
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
|
||||||
host_dict["open_ports"] = []
|
|
||||||
if not host_dict["mac"]:
|
|
||||||
host_dict["mac"] = nm_disc[ip].get("addresses", {}).get("mac")
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
# --- Pass B: version detection on the discovered ports (best-effort) ---
|
|
||||||
port_list = ",".join(str(p["port"]) for p in open_ports)
|
|
||||||
timeout = settings.scanner_version_host_timeout
|
|
||||||
version_args = f"{scan_type} -sV -T4 -Pn --host-timeout {timeout}s -p {port_list}"
|
|
||||||
logger.debug("[Phase 2] %s version args: %s", ip, version_args)
|
|
||||||
nm_ver = nmap.PortScanner()
|
|
||||||
banners: dict[tuple[str, int], str] = {}
|
|
||||||
ver_os = None
|
|
||||||
ver_mac = None
|
|
||||||
try:
|
|
||||||
nm_ver.scan(hosts=ip, arguments=version_args)
|
|
||||||
if ip in nm_ver.all_hosts():
|
|
||||||
for proto in nm_ver[ip].all_protocols():
|
|
||||||
for port, info in nm_ver[ip][proto].items():
|
|
||||||
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
|
||||||
if banner:
|
|
||||||
banners[(proto, port)] = banner
|
|
||||||
ver_os = _extract_os(nm_ver, ip)
|
|
||||||
ver_mac = nm_ver[ip].get("addresses", {}).get("mac")
|
|
||||||
else:
|
|
||||||
logger.info("[Phase 2] %s — version detection returned no results, keeping %d port(s) without banners",
|
|
||||||
ip, len(open_ports))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.info("[Phase 2] %s — version detection failed (%s: %s), keeping %d port(s) without banners",
|
|
||||||
ip, type(exc).__name__, exc, len(open_ports))
|
|
||||||
|
|
||||||
for p in open_ports:
|
|
||||||
p["banner"] = banners.get((p["protocol"], p["port"]), "")
|
|
||||||
|
|
||||||
port_summary = ", ".join(
|
|
||||||
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
|
||||||
)
|
|
||||||
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
|
||||||
|
|
||||||
host_dict["open_ports"] = open_ports
|
|
||||||
if not host_dict["mac"]:
|
|
||||||
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
|
|
||||||
host_dict["os"] = ver_os
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
|
|
||||||
async def _nmap_port_scan(
|
|
||||||
alive: dict[str, dict[str, Any]], port_spec: str = _EXTRA_PORTS,
|
|
||||||
run_id: str | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Phase 2: Per-IP service detection with bounded concurrency.
|
|
||||||
Each host is scanned independently in a thread — no inter-host timeout interference.
|
|
||||||
Up to 10 hosts scanned concurrently.
|
|
||||||
"""
|
|
||||||
if not alive:
|
|
||||||
return []
|
|
||||||
|
|
||||||
logger.info("[Phase 2] Starting per-IP port scan for %d host(s)", len(alive))
|
|
||||||
semaphore = asyncio.Semaphore(10)
|
|
||||||
|
|
||||||
async def _scan_with_sem(host_dict: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
async with semaphore:
|
|
||||||
# Once cancelled, skip the expensive nmap call for every host still
|
|
||||||
# queued behind the semaphore — return it unscanned so the gather
|
|
||||||
# unwinds fast instead of blocking the stop for minutes.
|
|
||||||
if run_id is not None and _is_cancelled(run_id):
|
|
||||||
return host_dict
|
|
||||||
return await asyncio.to_thread(_nmap_scan_single, host_dict, port_spec)
|
|
||||||
|
|
||||||
raw = await asyncio.gather(*[_scan_with_sem(h) for h in alive.values()], return_exceptions=True)
|
|
||||||
results = []
|
|
||||||
for item in raw:
|
|
||||||
if isinstance(item, BaseException):
|
|
||||||
logger.warning("[Phase 2] Unexpected error in gather: %s", item)
|
|
||||||
else:
|
|
||||||
results.append(item)
|
|
||||||
logger.info("[Phase 2] Completed — %d/%d host(s) scanned", len(results), len(alive))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
async def _nmap_scan(
|
|
||||||
target: str, port_spec: str = _EXTRA_PORTS, run_id: str | None = None
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Two-phase scan for a CIDR range.
|
|
||||||
Phase 1: Concurrent ping sweep to find alive hosts (fast, no false positives).
|
|
||||||
Phase 2: Per-IP nmap port scan with service detection (bounded concurrency, 10 at a time).
|
|
||||||
|
|
||||||
``run_id`` lets each phase poll for cancellation so a stop request takes
|
|
||||||
effect mid-range instead of only at CIDR/host boundaries in run_scan.
|
|
||||||
"""
|
|
||||||
logger.info("[Scan] Starting scan for %s — nmap available: %s", target, _NMAP_AVAILABLE)
|
|
||||||
if run_id is not None and _is_cancelled(run_id):
|
|
||||||
logger.info("[Scan] %s — cancelled before start, skipping", target)
|
|
||||||
return []
|
|
||||||
if not _NMAP_AVAILABLE:
|
|
||||||
logger.warning("[Scan] nmap not available — returning mock data")
|
|
||||||
return _mock_scan(target)
|
|
||||||
try:
|
|
||||||
alive = await _ping_sweep(target, run_id=run_id)
|
|
||||||
logger.info("[Phase 1] Found %d alive host(s) in %s: %s",
|
|
||||||
len(alive), target, ", ".join(sorted(alive.keys())))
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error("Phase 1 ping sweep failed: %s", exc)
|
|
||||||
raise RuntimeError(str(exc)) from exc
|
|
||||||
return await _nmap_port_scan(alive, port_spec, run_id=run_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def _mdns_discover(timeout: float = 4.0) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Passive mDNS/Bonjour sweep.
|
|
||||||
Returns devices advertising on _shelly._tcp, _esphomelib._tcp, _hap._tcp, etc.
|
|
||||||
Runs for `timeout` seconds then returns what it found.
|
|
||||||
"""
|
|
||||||
if not _ZEROCONF_AVAILABLE:
|
|
||||||
return []
|
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
|
|
||||||
found_services: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
def _on_change(
|
|
||||||
zeroconf: Any,
|
|
||||||
service_type: str,
|
|
||||||
name: str,
|
|
||||||
state_change: Any,
|
|
||||||
) -> None:
|
|
||||||
if state_change == ServiceStateChange.Added:
|
|
||||||
found_services.append((service_type, name))
|
|
||||||
|
|
||||||
discovered: dict[str, dict[str, Any]] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with AsyncZeroconf() as azc:
|
|
||||||
browser = AsyncServiceBrowser(
|
|
||||||
azc.zeroconf, _MDNS_SERVICE_TYPES, handlers=[_on_change]
|
|
||||||
)
|
|
||||||
await asyncio.sleep(timeout)
|
|
||||||
await browser.async_cancel()
|
|
||||||
|
|
||||||
for service_type, name in found_services:
|
|
||||||
try:
|
|
||||||
info = AsyncServiceInfo(service_type, name)
|
|
||||||
await info.async_request(azc.zeroconf, 3000)
|
|
||||||
if not info.addresses:
|
|
||||||
continue
|
|
||||||
ip = str(ipaddress.IPv4Address(info.addresses[0]))
|
|
||||||
if ip in discovered:
|
|
||||||
continue
|
|
||||||
discovered[ip] = {
|
|
||||||
"ip": ip,
|
|
||||||
"hostname": info.server,
|
|
||||||
"mac": None,
|
|
||||||
"os": None,
|
|
||||||
"open_ports": (
|
|
||||||
[{"port": info.port, "protocol": "tcp", "banner": ""}]
|
|
||||||
if info.port else []
|
|
||||||
),
|
|
||||||
}
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("mDNS resolution failed for %s: %s", name, exc)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("mDNS discovery error: %s", exc)
|
|
||||||
|
|
||||||
logger.info("mDNS discovery found %d device(s)", len(discovered))
|
|
||||||
return list(discovered.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
def _mock_scan(target: str) -> list[dict[str, Any]]:
|
||||||
"""Return fake results for dev/test environments without nmap."""
|
"""Return fake results for dev/test environments without nmap."""
|
||||||
return [
|
return [
|
||||||
@@ -469,217 +98,75 @@ def _mock_scan(target: str) -> list[dict[str, Any]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
async def _dedupe_pending_by_ip(db: AsyncSession) -> int:
|
async def run_scan(ranges: list[str], db: AsyncSession, run_id: str) -> None:
|
||||||
"""Collapse duplicate non-hidden inventory rows that share an IP into one.
|
|
||||||
|
|
||||||
Keeps an ``approved`` row when present (it carries canvas-link semantics),
|
|
||||||
otherwise the oldest row, and deletes the rest. Returns the number deleted.
|
|
||||||
"""
|
|
||||||
rows = (await db.execute(
|
|
||||||
select(PendingDevice)
|
|
||||||
.where(PendingDevice.status != "hidden", PendingDevice.ip.isnot(None))
|
|
||||||
.order_by(PendingDevice.discovered_at)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
by_ip: dict[str, list[PendingDevice]] = {}
|
|
||||||
for row in rows:
|
|
||||||
if row.ip is None: # guarded by the query, but keeps the type checker happy
|
|
||||||
continue
|
|
||||||
by_ip.setdefault(row.ip, []).append(row)
|
|
||||||
|
|
||||||
deleted = 0
|
|
||||||
for group in by_ip.values():
|
|
||||||
if len(group) < 2:
|
|
||||||
continue
|
|
||||||
keep = next((r for r in group if r.status == "approved"), group[0])
|
|
||||||
for dup in group:
|
|
||||||
if dup is not keep:
|
|
||||||
await db.delete(dup)
|
|
||||||
deleted += 1
|
|
||||||
if deleted:
|
|
||||||
await db.commit()
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
|
|
||||||
async def run_scan(
|
|
||||||
ranges: list[str],
|
|
||||||
db: AsyncSession,
|
|
||||||
run_id: str,
|
|
||||||
deep_scan: DeepScanOptions | None = None,
|
|
||||||
) -> None:
|
|
||||||
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
"""Execute scan for given CIDR ranges and populate pending_devices."""
|
||||||
|
# Avoid circular import
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.status import broadcast_scan_update
|
from app.api.routes.status import broadcast_scan_update
|
||||||
|
|
||||||
deep_scan = deep_scan or DeepScanOptions()
|
|
||||||
port_spec = _build_port_spec(deep_scan.http_ranges)
|
|
||||||
|
|
||||||
devices_found = 0
|
devices_found = 0
|
||||||
mdns_task: asyncio.Task[list[dict[str, Any]]] | None = None
|
|
||||||
try:
|
try:
|
||||||
# Validate all ranges are valid CIDRs before passing anything to nmap
|
for cidr in ranges:
|
||||||
for r in ranges:
|
# Run nmap in a thread pool — does not block the event loop
|
||||||
try:
|
hosts = await asyncio.to_thread(_nmap_scan, cidr)
|
||||||
ipaddress.ip_network(r, strict=False)
|
|
||||||
except ValueError:
|
|
||||||
raise ValueError(f"Invalid CIDR range: {r!r}") from None
|
|
||||||
|
|
||||||
# Pre-fetch hidden IPs once — avoids N+1 queries per host.
|
for host in hosts:
|
||||||
# Devices already on a canvas are intentionally NOT suppressed: they stay
|
services = fingerprint_ports(host["open_ports"])
|
||||||
# in the inventory and are badged "In N canvas" via per-request correlation.
|
suggested_type = suggest_node_type(host["open_ports"], host.get("mac"))
|
||||||
hidden_ips_result = await db.execute(
|
|
||||||
select(PendingDevice.ip).where(PendingDevice.status == "hidden")
|
# Update existing pending device or create a new one
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(PendingDevice).where(
|
||||||
|
PendingDevice.ip == host["ip"],
|
||||||
|
PendingDevice.status == "pending",
|
||||||
)
|
)
|
||||||
hidden_ips: set[str] = {row[0] for row in hidden_ips_result.fetchall()}
|
|
||||||
|
|
||||||
# Collapse any pre-existing duplicate inventory rows (same IP, non-hidden)
|
|
||||||
# left over from older scans, so the device shows up exactly once even if
|
|
||||||
# it isn't re-discovered this run (e.g. now offline).
|
|
||||||
await _dedupe_pending_by_ip(db)
|
|
||||||
|
|
||||||
# Start mDNS discovery in the background while nmap scans run
|
|
||||||
mdns_task = asyncio.create_task(_mdns_discover())
|
|
||||||
|
|
||||||
# Track IPs found by nmap so mDNS doesn't duplicate them
|
|
||||||
nmap_ips: set[str] = set()
|
|
||||||
|
|
||||||
async def _process_host(host: dict[str, Any], discovery_source: str = "arp") -> None:
|
|
||||||
nonlocal devices_found
|
|
||||||
ip = host["ip"]
|
|
||||||
|
|
||||||
# Skip only user-hidden devices. On-canvas devices are kept so they
|
|
||||||
# surface in the inventory with a canvas-presence badge.
|
|
||||||
if ip in hidden_ips:
|
|
||||||
logger.debug("Skipping %s — hidden by user", ip)
|
|
||||||
return
|
|
||||||
|
|
||||||
open_ports = host["open_ports"]
|
|
||||||
# Deep-scan HTTP probe: enrich open ports with title/header signals so
|
|
||||||
# fingerprint can confirm services on custom ports. No-op when disabled
|
|
||||||
# or when the host has no open ports (e.g. mDNS-only discovery).
|
|
||||||
if deep_scan.http_probe_enabled and open_ports:
|
|
||||||
open_ports = await probe_open_ports(
|
|
||||||
ip, open_ports, verify_tls=deep_scan.verify_tls
|
|
||||||
)
|
)
|
||||||
|
existing = existing_result.scalar_one_or_none()
|
||||||
norm_mac = normalize_mac(host.get("mac"))
|
if existing:
|
||||||
services = fingerprint_ports(open_ports)
|
existing.mac = host.get("mac") or existing.mac
|
||||||
suggested_type = suggest_node_type(open_ports, norm_mac)
|
existing.hostname = host.get("hostname") or existing.hostname
|
||||||
|
existing.os = host.get("os") or existing.os
|
||||||
# One inventory row per device. Match by IP OR MAC across pending AND
|
existing.services = services
|
||||||
# approved so a re-scan refreshes the existing row instead of spawning
|
existing.suggested_type = suggested_type
|
||||||
# a duplicate — and so a device previously imported from Proxmox (which
|
|
||||||
# may have no IP but a known NIC MAC) reconciles with this scan instead
|
|
||||||
# of doubling up. Hidden rows are already skipped above.
|
|
||||||
match_cond = [PendingDevice.ip == ip]
|
|
||||||
if norm_mac:
|
|
||||||
match_cond.append(PendingDevice.mac == norm_mac)
|
|
||||||
existing_rows = (await db.execute(
|
|
||||||
select(PendingDevice)
|
|
||||||
.where(or_(*match_cond), PendingDevice.status != "hidden")
|
|
||||||
.order_by(PendingDevice.discovered_at)
|
|
||||||
)).scalars().all()
|
|
||||||
|
|
||||||
if existing_rows:
|
|
||||||
# Prefer an approved row (it owns the canvas link semantics),
|
|
||||||
# otherwise the oldest. Collapse any leftover duplicates created
|
|
||||||
# by earlier scans.
|
|
||||||
keep = next((r for r in existing_rows if r.status == "approved"), existing_rows[0])
|
|
||||||
for dup in existing_rows:
|
|
||||||
if dup is not keep:
|
|
||||||
await db.delete(dup)
|
|
||||||
keep.ip = keep.ip or ip # fill an IP a Proxmox import lacked
|
|
||||||
keep.mac = norm_mac or keep.mac
|
|
||||||
keep.hostname = host.get("hostname") or keep.hostname
|
|
||||||
keep.os = host.get("os") or keep.os
|
|
||||||
keep.services = services
|
|
||||||
# Don't downgrade a Proxmox-typed guest (vm/lxc) to the generic
|
|
||||||
# scan guess; the importer knows the true type.
|
|
||||||
if not (keep.ieee_address or "").startswith("pve-"):
|
|
||||||
keep.suggested_type = suggested_type
|
|
||||||
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
|
|
||||||
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
|
|
||||||
# status preserved — an approved device stays approved.
|
|
||||||
else:
|
else:
|
||||||
db.add(PendingDevice(
|
device = PendingDevice(
|
||||||
ip=ip,
|
ip=host["ip"],
|
||||||
mac=norm_mac,
|
mac=host.get("mac"),
|
||||||
hostname=host.get("hostname"),
|
hostname=host.get("hostname"),
|
||||||
os=host.get("os"),
|
os=host.get("os"),
|
||||||
services=services,
|
services=services,
|
||||||
suggested_type=suggested_type,
|
suggested_type=suggested_type,
|
||||||
status="pending",
|
status="pending",
|
||||||
discovery_source=discovery_source,
|
)
|
||||||
discovery_sources=[discovery_source],
|
db.add(device)
|
||||||
))
|
|
||||||
devices_found += 1
|
devices_found += 1
|
||||||
|
|
||||||
# Stamp last_scan on any canvas node that matches this device by IP
|
# Commit immediately so the device is visible right away
|
||||||
# (or MAC, when known) so the inventory shows when the scanner last
|
|
||||||
# observed it. Match both the normalized and raw MAC so a legacy
|
|
||||||
# canvas node whose mac predates normalization still matches. Across
|
|
||||||
# designs.
|
|
||||||
node_match = [Node.ip == ip]
|
|
||||||
for m in {norm_mac, host.get("mac")}:
|
|
||||||
if m:
|
|
||||||
node_match.append(Node.mac == m)
|
|
||||||
matching_nodes = (await db.execute(
|
|
||||||
select(Node).where(or_(*node_match))
|
|
||||||
)).scalars().all()
|
|
||||||
scanned_at = datetime.now(timezone.utc)
|
|
||||||
for node in matching_nodes:
|
|
||||||
node.last_scan = scanned_at
|
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
|
||||||
|
|
||||||
# nmap scan per CIDR — results stream in progressively
|
# Update running count on the scan run record
|
||||||
for cidr in ranges:
|
|
||||||
if _is_cancelled(run_id):
|
|
||||||
break
|
|
||||||
hosts = await _nmap_scan(cidr, port_spec, run_id=run_id)
|
|
||||||
for host in hosts:
|
|
||||||
if _is_cancelled(run_id):
|
|
||||||
break
|
|
||||||
nmap_ips.add(host["ip"])
|
|
||||||
await _process_host(host)
|
|
||||||
|
|
||||||
# Update ScanRun count once after all CIDR ranges
|
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# Collect mDNS results — task already has its own 4s internal timeout
|
# Push WS event so the frontend refreshes pending panel
|
||||||
if not _is_cancelled(run_id):
|
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
|
||||||
mdns_hosts = await mdns_task
|
|
||||||
|
|
||||||
for host in mdns_hosts:
|
# Mark scan as done
|
||||||
if _is_cancelled(run_id):
|
|
||||||
break
|
|
||||||
if host["ip"] in nmap_ips:
|
|
||||||
continue # already processed with richer nmap data
|
|
||||||
await _process_host(host, discovery_source="mdns")
|
|
||||||
else:
|
|
||||||
mdns_task.cancel()
|
|
||||||
|
|
||||||
# Mark scan as done or cancelled
|
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "cancelled" if _is_cancelled(run_id) else "done"
|
run.status = "done"
|
||||||
run.devices_found = devices_found
|
run.devices_found = devices_found
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Scan failed: %s", exc)
|
logger.error("Scan failed: %s", exc)
|
||||||
if mdns_task is not None and not mdns_task.done():
|
|
||||||
mdns_task.cancel()
|
|
||||||
run = await db.get(ScanRun, run_id)
|
run = await db.get(ScanRun, run_id)
|
||||||
if run:
|
if run:
|
||||||
run.status = "error"
|
run.status = "error"
|
||||||
run.error = str(exc)
|
run.error = str(exc)
|
||||||
run.finished_at = datetime.now(timezone.utc)
|
run.finished_at = datetime.now(timezone.utc)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
|
||||||
with _cancelled_lock:
|
|
||||||
_cancelled_runs.discard(run_id)
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import sys
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -19,16 +18,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
if check_method == "none":
|
if check_method == "none":
|
||||||
return {"status": "online", "response_time_ms": None}
|
return {"status": "online", "response_time_ms": None}
|
||||||
|
|
||||||
# Use only the first IP when the field contains comma-separated addresses
|
host = target or ip
|
||||||
raw_ip = ip.split(",")[0].strip() if ip else None
|
|
||||||
host = target or raw_ip
|
|
||||||
if not host:
|
if not host:
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
return {"status": "unknown", "response_time_ms": None}
|
||||||
# Reject hostnames that look like CLI flags — defends ping/tcp invocations
|
|
||||||
# against arg-injection if a malicious admin sets target like "-O".
|
|
||||||
if host.startswith("-"):
|
|
||||||
logger.warning("Rejecting check target that starts with '-': %r", host)
|
|
||||||
return {"status": "unknown", "response_time_ms": None}
|
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
@@ -64,37 +56,9 @@ async def check_node(check_method: str, target: str | None, ip: str | None) -> d
|
|||||||
return {"status": "offline", "response_time_ms": None}
|
return {"status": "offline", "response_time_ms": None}
|
||||||
|
|
||||||
|
|
||||||
def _is_ipv6(host: str) -> bool:
|
|
||||||
"""True if host is a literal IPv6 address (bracketed or bare)."""
|
|
||||||
try:
|
|
||||||
socket.inet_pton(socket.AF_INET6, host.strip("[]"))
|
|
||||||
return True
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _ping(host: str) -> bool:
|
async def _ping(host: str) -> bool:
|
||||||
# Send 2 probes with a ~2s timeout so a single dropped packet or a slow
|
|
||||||
# device (ESPHome, IoT) doesn't flap a node offline. Success = any reply.
|
|
||||||
#
|
|
||||||
# -W flag units differ by OS:
|
|
||||||
# Linux: seconds (-W 2 = 2s)
|
|
||||||
# macOS: milliseconds (-W 2000 = 2s)
|
|
||||||
# Windows: -w in ms (-w 2000 = 2s)
|
|
||||||
#
|
|
||||||
# IPv6-only hosts (e.g. Alexa) never answer IPv4 ping, so target the right
|
|
||||||
# stack: macOS ships a separate ping6; Linux/Windows take a -6 flag.
|
|
||||||
ipv6 = _is_ipv6(host)
|
|
||||||
if sys.platform == "win32":
|
|
||||||
family = ["-6"] if ipv6 else ["-4"]
|
|
||||||
args = ["ping", *family, "-n", "2", "-w", "2000", host]
|
|
||||||
elif sys.platform == "darwin":
|
|
||||||
args = ["ping6", "-c", "2", host] if ipv6 else ["ping", "-c", "2", "-W", "2000", host]
|
|
||||||
else:
|
|
||||||
family = ["-6"] if ipv6 else []
|
|
||||||
args = ["ping", *family, "-c", "2", "-W", "2", host]
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
"ping", "-c", "1", "-W", "1", host,
|
||||||
stdout=asyncio.subprocess.DEVNULL,
|
stdout=asyncio.subprocess.DEVNULL,
|
||||||
stderr=asyncio.subprocess.DEVNULL,
|
stderr=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
@@ -118,75 +82,3 @@ async def _tcp_connect(host: str, port: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
except (TimeoutError, OSError, socket.gaierror):
|
except (TimeoutError, OSError, socket.gaierror):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# --- Per-service status checks ---
|
|
||||||
|
|
||||||
# Ports that are not HTTP/web. These get NO status check — a service here stays
|
|
||||||
# grey (unknown) rather than going red. An open TCP socket doesn't prove the
|
|
||||||
# service is healthy, and a closed one flaps red misleadingly (e.g. SSH on a
|
|
||||||
# box that simply firewalls 22). Only HTTP(S)-reachable services are checked.
|
|
||||||
_NON_HTTP_PORTS = frozenset({
|
|
||||||
22, 21, 23, 25, 465, 587, 53, 110, 143, 993, 995, 389, 636, 445, 514,
|
|
||||||
1433, 3306, 5432, 5672, 6379, 9092, 11211, 27017, 27018,
|
|
||||||
})
|
|
||||||
_HTTPS_PORTS = frozenset({443, 8443})
|
|
||||||
|
|
||||||
|
|
||||||
def _service_host(svc: dict[str, Any], host: str) -> str:
|
|
||||||
"""Bracket bare IPv6 literals for use in a URL."""
|
|
||||||
return f"[{host}]" if _is_ipv6(host) else host
|
|
||||||
|
|
||||||
|
|
||||||
async def check_service(svc: dict[str, Any], host: str | None) -> str:
|
|
||||||
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
|
|
||||||
|
|
||||||
Only HTTP(S)-reachable services get a real check (an HTTP GET). Everything
|
|
||||||
else — SSH, databases, mail, DNS, raw TCP, UDP, port-less — stays 'unknown'
|
|
||||||
so it keeps its category colour instead of flashing red. An open TCP socket
|
|
||||||
doesn't prove a non-web service is healthy, so we don't pretend it does.
|
|
||||||
"""
|
|
||||||
if not host or host.startswith("-"):
|
|
||||||
return "unknown"
|
|
||||||
if str(svc.get("protocol", "")).lower() == "udp":
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
port = svc.get("port")
|
|
||||||
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
|
|
||||||
|
|
||||||
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
|
|
||||||
if port is not None and port in _NON_HTTP_PORTS:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
name = str(svc.get("service_name", "")).lower()
|
|
||||||
is_web = port is not None or "http" in name
|
|
||||||
if not is_web:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
try:
|
|
||||||
scheme = "https" if (
|
|
||||||
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
|
|
||||||
) else "http"
|
|
||||||
url_host = _service_host(svc, host)
|
|
||||||
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
|
|
||||||
return "online" if await _http_get(url, verify=False) else "offline"
|
|
||||||
except Exception as exc:
|
|
||||||
logger.debug("Service check failed for %s:%s (%s)", host, port, exc)
|
|
||||||
return "offline"
|
|
||||||
|
|
||||||
|
|
||||||
async def check_services(
|
|
||||||
host: str | None, services: list[dict[str, Any]], concurrency: int = 10
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Check every service against host concurrently (bounded).
|
|
||||||
|
|
||||||
Returns a list of {port, protocol, status} dicts, one per input service.
|
|
||||||
"""
|
|
||||||
sem = asyncio.Semaphore(concurrency)
|
|
||||||
|
|
||||||
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
async with sem:
|
|
||||||
status = await check_service(svc, host)
|
|
||||||
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
|
|
||||||
|
|
||||||
return await asyncio.gather(*[_one(s) for s in services]) if services else []
|
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
"""Zigbee2MQTT service: connects to MQTT broker and fetches the network map."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.services.mqtt_common import _build_tls_context, _sanitize_mqtt_error
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import aiomqtt
|
|
||||||
except ImportError: # pragma: no cover
|
|
||||||
aiomqtt = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
_NETWORKMAP_REQUEST_TOPIC = "{base_topic}/bridge/request/networkmap"
|
|
||||||
_NETWORKMAP_RESPONSE_TOPIC = "{base_topic}/bridge/response/networkmap"
|
|
||||||
_CONNECTION_TIMEOUT = 5.0 # seconds to verify broker reachability
|
|
||||||
_NETWORKMAP_TIMEOUT = 300.0 # seconds to wait for the networkmap response (large meshes can be slow)
|
|
||||||
|
|
||||||
# Re-exported for backwards compatibility — these now live in mqtt_common.
|
|
||||||
__all__ = ["_build_tls_context", "_sanitize_mqtt_error"]
|
|
||||||
|
|
||||||
|
|
||||||
def build_zigbee_properties(
|
|
||||||
ieee: str | None,
|
|
||||||
vendor: str | None,
|
|
||||||
model: str | None,
|
|
||||||
lqi: int | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build a NodeProperty list for a Zigbee device (IEEE, Vendor, Model, LQI).
|
|
||||||
|
|
||||||
Only includes a row when the value is non-empty. Shape matches the
|
|
||||||
frontend ``NodeProperty`` type: ``{key, value, icon, visible}``.
|
|
||||||
|
|
||||||
New props default to ``visible=False`` — users opt in to showing them on
|
|
||||||
the canvas card from the right panel.
|
|
||||||
"""
|
|
||||||
props: list[dict[str, Any]] = []
|
|
||||||
if ieee:
|
|
||||||
props.append({"key": "IEEE", "value": ieee, "icon": None, "visible": False})
|
|
||||||
if vendor:
|
|
||||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
|
||||||
if model:
|
|
||||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
|
||||||
if lqi is not None:
|
|
||||||
props.append({"key": "LQI", "value": str(lqi), "icon": None, "visible": False})
|
|
||||||
return props
|
|
||||||
|
|
||||||
|
|
||||||
def merge_zigbee_properties(
|
|
||||||
existing: list[dict[str, Any]] | None,
|
|
||||||
new_props: list[dict[str, Any]],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Merge fresh zigbee props into an existing property list.
|
|
||||||
|
|
||||||
For keys already present: update ``value`` but preserve the user's
|
|
||||||
``visible`` choice. New keys are appended with whatever visibility the
|
|
||||||
caller gave them (hidden by default per ``build_zigbee_properties``).
|
|
||||||
Non-zigbee custom properties are preserved untouched.
|
|
||||||
"""
|
|
||||||
out = [dict(p) for p in (existing or [])]
|
|
||||||
by_key = {p.get("key"): p for p in out}
|
|
||||||
for np in new_props:
|
|
||||||
key = np.get("key")
|
|
||||||
if key in by_key:
|
|
||||||
by_key[key]["value"] = np.get("value")
|
|
||||||
else:
|
|
||||||
out.append(dict(np))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _z2m_type_to_homelable(device_type: str) -> str:
|
|
||||||
"""Map a Z2M device type string to a homelable node type."""
|
|
||||||
mapping = {
|
|
||||||
"Coordinator": "zigbee_coordinator",
|
|
||||||
"Router": "zigbee_router",
|
|
||||||
"EndDevice": "zigbee_enddevice",
|
|
||||||
}
|
|
||||||
return mapping.get(device_type, "zigbee_enddevice")
|
|
||||||
|
|
||||||
|
|
||||||
def _node_from_z2m(raw: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""Build a homelable node dict from a Z2M raw networkmap node entry."""
|
|
||||||
ieee: str = raw.get("ieeeAddr") or raw.get("ieee_address") or ""
|
|
||||||
if not ieee:
|
|
||||||
return None
|
|
||||||
device_type: str = raw.get("type") or "EndDevice"
|
|
||||||
friendly_name: str = (
|
|
||||||
raw.get("friendlyName") or raw.get("friendly_name") or ieee
|
|
||||||
)
|
|
||||||
definition: dict[str, Any] = raw.get("definition") or {}
|
|
||||||
model: str | None = (
|
|
||||||
raw.get("modelID")
|
|
||||||
or raw.get("model")
|
|
||||||
or definition.get("model")
|
|
||||||
or None
|
|
||||||
)
|
|
||||||
vendor: str | None = raw.get("vendor") or definition.get("vendor") or None
|
|
||||||
return {
|
|
||||||
"id": ieee,
|
|
||||||
"label": friendly_name,
|
|
||||||
"type": _z2m_type_to_homelable(device_type),
|
|
||||||
"ieee_address": ieee,
|
|
||||||
"friendly_name": friendly_name,
|
|
||||||
"device_type": device_type,
|
|
||||||
"model": model,
|
|
||||||
"vendor": vendor,
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def parse_networkmap(
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Parse a Z2M ``bridge/response/networkmap`` payload into node + edge lists.
|
|
||||||
|
|
||||||
Z2M raw response shape::
|
|
||||||
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"type": "raw",
|
|
||||||
"routes": false,
|
|
||||||
"value": {
|
|
||||||
"nodes": [{"ieeeAddr": ..., "type": "Coordinator|Router|EndDevice",
|
|
||||||
"friendlyName": ..., "definition": {"model": ..., "vendor": ...}}],
|
|
||||||
"links": [{"source": {"ieeeAddr": ...}, "target": {"ieeeAddr": ...},
|
|
||||||
"lqi": 200, "depth": 1}]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"status": "ok"
|
|
||||||
}
|
|
||||||
|
|
||||||
Older or alternate shapes may put nodes/links directly under ``data``.
|
|
||||||
Both are accepted.
|
|
||||||
"""
|
|
||||||
data: dict[str, Any] = payload.get("data") or {}
|
|
||||||
value = data.get("value")
|
|
||||||
container: dict[str, Any] = value if isinstance(value, dict) else data
|
|
||||||
|
|
||||||
raw_nodes: list[dict[str, Any]] = container.get("nodes") or []
|
|
||||||
raw_links: list[dict[str, Any]] = container.get("links") or []
|
|
||||||
|
|
||||||
if not isinstance(raw_nodes, list):
|
|
||||||
raise ValueError("Malformed networkmap: 'nodes' is not a list")
|
|
||||||
if not isinstance(raw_links, list):
|
|
||||||
raise ValueError("Malformed networkmap: 'links' is not a list")
|
|
||||||
|
|
||||||
nodes_list: list[dict[str, Any]] = []
|
|
||||||
seen_ids: set[str] = set()
|
|
||||||
coordinator_id: str | None = None
|
|
||||||
|
|
||||||
for entry in raw_nodes:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
node = _node_from_z2m(entry)
|
|
||||||
if node is None or node["id"] in seen_ids:
|
|
||||||
continue
|
|
||||||
seen_ids.add(node["id"])
|
|
||||||
nodes_list.append(node)
|
|
||||||
if node["device_type"] == "Coordinator":
|
|
||||||
coordinator_id = node["id"]
|
|
||||||
|
|
||||||
# Z2M `links` is bidirectional/mesh: every pair appears twice and routers
|
|
||||||
# carry sibling-mesh paths. Walk it only to extract LQI per device and to
|
|
||||||
# resolve which router an end device hangs off; do NOT emit edges directly
|
|
||||||
# from links. The final edge set is the strict parent→child tree built
|
|
||||||
# from parent_id below — that avoids duplicate edges and keeps the visual
|
|
||||||
# flow consistent (parent bottom → child top).
|
|
||||||
raw_edges: list[dict[str, Any]] = []
|
|
||||||
lqi_by_id: dict[str, int] = {}
|
|
||||||
|
|
||||||
for link in raw_links:
|
|
||||||
if not isinstance(link, dict):
|
|
||||||
continue
|
|
||||||
src_obj = link.get("source") or {}
|
|
||||||
tgt_obj = link.get("target") or {}
|
|
||||||
src = src_obj.get("ieeeAddr") if isinstance(src_obj, dict) else None
|
|
||||||
tgt = tgt_obj.get("ieeeAddr") if isinstance(tgt_obj, dict) else None
|
|
||||||
if not src or not tgt:
|
|
||||||
continue
|
|
||||||
if src not in seen_ids or tgt not in seen_ids:
|
|
||||||
continue
|
|
||||||
raw_edges.append({"source": src, "target": tgt})
|
|
||||||
lqi = link.get("lqi") or link.get("linkquality")
|
|
||||||
if isinstance(lqi, int) and tgt not in lqi_by_id:
|
|
||||||
lqi_by_id[tgt] = lqi
|
|
||||||
|
|
||||||
for node in nodes_list:
|
|
||||||
if node["id"] in lqi_by_id:
|
|
||||||
node["lqi"] = lqi_by_id[node["id"]]
|
|
||||||
|
|
||||||
# Build parent_id hierarchy: coordinator → routers → end devices
|
|
||||||
if coordinator_id:
|
|
||||||
router_ids = {n["id"] for n in nodes_list if n["device_type"] == "Router"}
|
|
||||||
for node in nodes_list:
|
|
||||||
if node["device_type"] == "Router":
|
|
||||||
node["parent_id"] = coordinator_id
|
|
||||||
elif node["device_type"] == "EndDevice":
|
|
||||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
|
||||||
node["parent_id"] = parent or coordinator_id
|
|
||||||
|
|
||||||
# Final edges = strict parent → child tree (one edge per non-coordinator)
|
|
||||||
edges_list: list[dict[str, Any]] = [
|
|
||||||
{"source": node["parent_id"], "target": node["id"]}
|
|
||||||
for node in nodes_list
|
|
||||||
if node.get("parent_id")
|
|
||||||
]
|
|
||||||
|
|
||||||
return nodes_list, edges_list
|
|
||||||
|
|
||||||
|
|
||||||
def _find_parent_router(
|
|
||||||
device_id: str,
|
|
||||||
router_ids: set[str],
|
|
||||||
edges: list[dict[str, Any]],
|
|
||||||
) -> str | None:
|
|
||||||
"""Return the first router that has a direct edge to device_id."""
|
|
||||||
for edge in edges:
|
|
||||||
src: str = edge["source"]
|
|
||||||
tgt: str = edge["target"]
|
|
||||||
if tgt == device_id and src in router_ids:
|
|
||||||
return src
|
|
||||||
if src == device_id and tgt in router_ids:
|
|
||||||
return tgt
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_networkmap(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
base_topic: str,
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Connect to the MQTT broker, request the Z2M networkmap, and return (nodes, edges).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TimeoutError: if the broker does not respond in time.
|
|
||||||
ConnectionError: if the broker cannot be reached.
|
|
||||||
ValueError: if the response payload is malformed.
|
|
||||||
"""
|
|
||||||
if aiomqtt is None: # pragma: no cover
|
|
||||||
raise ImportError(
|
|
||||||
"aiomqtt is required for Zigbee import. "
|
|
||||||
"Install it with: pip install aiomqtt"
|
|
||||||
)
|
|
||||||
|
|
||||||
request_topic = _NETWORKMAP_REQUEST_TOPIC.format(base_topic=base_topic)
|
|
||||||
response_topic = _NETWORKMAP_RESPONSE_TOPIC.format(base_topic=base_topic)
|
|
||||||
|
|
||||||
response_payload: dict[str, Any] = {}
|
|
||||||
|
|
||||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiomqtt.Client(
|
|
||||||
hostname=mqtt_host,
|
|
||||||
port=mqtt_port,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
timeout=_CONNECTION_TIMEOUT,
|
|
||||||
tls_context=tls_context,
|
|
||||||
) as client:
|
|
||||||
await client.subscribe(response_topic)
|
|
||||||
# Give the broker a brief window to register the subscription
|
|
||||||
# before we publish the request. Without this, brokers that
|
|
||||||
# race SUBACK with our PUBLISH may deliver the response before
|
|
||||||
# the subscription is active and we'd hang until timeout.
|
|
||||||
await asyncio.sleep(0.1)
|
|
||||||
await client.publish(
|
|
||||||
request_topic,
|
|
||||||
json.dumps({"type": "raw", "routes": False}),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _wait_for_response() -> None:
|
|
||||||
async for message in client.messages:
|
|
||||||
if str(message.topic) != response_topic:
|
|
||||||
continue
|
|
||||||
raw = message.payload
|
|
||||||
try:
|
|
||||||
payload_str = (
|
|
||||||
raw.decode() if isinstance(raw, bytes | bytearray) else str(raw)
|
|
||||||
)
|
|
||||||
response_payload.update(json.loads(payload_str))
|
|
||||||
except (json.JSONDecodeError, TypeError) as exc:
|
|
||||||
raise ValueError(
|
|
||||||
f"Malformed networkmap response: {exc}"
|
|
||||||
) from exc
|
|
||||||
return
|
|
||||||
|
|
||||||
await asyncio.wait_for(_wait_for_response(), timeout=_NETWORKMAP_TIMEOUT)
|
|
||||||
|
|
||||||
except aiomqtt.MqttError as exc:
|
|
||||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
raise TimeoutError("Timed out waiting for networkmap response") from exc
|
|
||||||
|
|
||||||
if not response_payload:
|
|
||||||
raise ValueError("Empty networkmap response received")
|
|
||||||
|
|
||||||
return parse_networkmap(response_payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mqtt_connection(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Attempt a quick MQTT connection to verify broker reachability.
|
|
||||||
|
|
||||||
Returns True on success, raises ConnectionError on failure.
|
|
||||||
"""
|
|
||||||
if aiomqtt is None: # pragma: no cover
|
|
||||||
raise ImportError("aiomqtt is required")
|
|
||||||
|
|
||||||
tls_context = _build_tls_context(tls_insecure) if tls else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiomqtt.Client(
|
|
||||||
hostname=mqtt_host,
|
|
||||||
port=mqtt_port,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
timeout=_CONNECTION_TIMEOUT,
|
|
||||||
tls_context=tls_context,
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
except aiomqtt.MqttError as exc:
|
|
||||||
raise ConnectionError(_sanitize_mqtt_error(exc)) from exc
|
|
||||||
except asyncio.TimeoutError as exc:
|
|
||||||
raise TimeoutError("Connection to broker timed out") from exc
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
"""Z-Wave JS UI (zwavejs2mqtt) service: fetch the node list via the MQTT gateway API.
|
|
||||||
|
|
||||||
Mirrors the Zigbee pipeline. Z-Wave JS UI exposes a request/response gateway over
|
|
||||||
MQTT: publish to ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes/set`` and
|
|
||||||
read the answer from ``<prefix>/_CLIENTS/ZWAVE_GATEWAY-<gateway>/api/getNodes``.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.services.mqtt_common import request_response, test_connection
|
|
||||||
from app.services.zigbee_service import _find_parent_router, merge_zigbee_properties
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Reuse the zigbee merge logic verbatim — same NodeProperty shape + visibility rules.
|
|
||||||
merge_zwave_properties = merge_zigbee_properties
|
|
||||||
|
|
||||||
_REQUEST_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes/set"
|
|
||||||
_RESPONSE_TOPIC = "{prefix}/_CLIENTS/ZWAVE_GATEWAY-{gateway}/api/getNodes"
|
|
||||||
|
|
||||||
|
|
||||||
def _zwave_type_to_homelable(raw: dict[str, Any]) -> str:
|
|
||||||
"""Map a Z-Wave node's role flags to a homelable node type.
|
|
||||||
|
|
||||||
Controller → coordinator. Mains-powered / routing nodes → router.
|
|
||||||
Everything else (battery sensors, etc.) → end device.
|
|
||||||
"""
|
|
||||||
if raw.get("isControllerNode"):
|
|
||||||
return "zwave_coordinator"
|
|
||||||
if raw.get("isRouting"):
|
|
||||||
return "zwave_router"
|
|
||||||
return "zwave_enddevice"
|
|
||||||
|
|
||||||
|
|
||||||
def _role_label(node_type: str) -> str:
|
|
||||||
"""Human role string stored as ``device_subtype`` / ``device_type``."""
|
|
||||||
return {
|
|
||||||
"zwave_coordinator": "Controller",
|
|
||||||
"zwave_router": "Router",
|
|
||||||
"zwave_enddevice": "EndDevice",
|
|
||||||
}.get(node_type, "EndDevice")
|
|
||||||
|
|
||||||
|
|
||||||
def _node_from_zwave(raw: dict[str, Any], home_id: str) -> dict[str, Any] | None:
|
|
||||||
"""Build a homelable node dict from a Z-Wave JS UI ``getNodes`` entry."""
|
|
||||||
node_id = raw.get("id")
|
|
||||||
if node_id is None:
|
|
||||||
return None
|
|
||||||
ieee = f"zwave-{home_id}-{node_id}"
|
|
||||||
node_type = _zwave_type_to_homelable(raw)
|
|
||||||
name = raw.get("name") or raw.get("loc") or f"Node {node_id}"
|
|
||||||
model = raw.get("productLabel") or raw.get("productDescription") or None
|
|
||||||
vendor = raw.get("manufacturer") or None
|
|
||||||
return {
|
|
||||||
"id": ieee,
|
|
||||||
"label": name,
|
|
||||||
"type": node_type,
|
|
||||||
"ieee_address": ieee,
|
|
||||||
"friendly_name": name,
|
|
||||||
"device_type": _role_label(node_type),
|
|
||||||
"node_id": node_id,
|
|
||||||
"model": model,
|
|
||||||
"vendor": vendor,
|
|
||||||
"lqi": None, # Z-Wave has no LQI; RSSI may be added later.
|
|
||||||
"parent_id": None,
|
|
||||||
"neighbors": raw.get("neighbors") or [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_home_id(raw_nodes: list[dict[str, Any]]) -> str:
|
|
||||||
"""Pick a home id for the network: prefer the controller's, else any node's."""
|
|
||||||
controller_home = None
|
|
||||||
for entry in raw_nodes:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
home = entry.get("homeId")
|
|
||||||
if home is None:
|
|
||||||
continue
|
|
||||||
if entry.get("isControllerNode"):
|
|
||||||
return str(home)
|
|
||||||
if controller_home is None:
|
|
||||||
controller_home = str(home)
|
|
||||||
return controller_home or "0"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_zwave_nodes(
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Parse a Z-Wave JS UI ``getNodes`` response into (nodes, edges).
|
|
||||||
|
|
||||||
Expected shape::
|
|
||||||
|
|
||||||
{"success": true, "result": [ {<node>}, ... ]}
|
|
||||||
|
|
||||||
Edges are a strict coordinator → router → end-device tree, derived from
|
|
||||||
each node's ``neighbors`` list (same approach as the Zigbee parser).
|
|
||||||
"""
|
|
||||||
if payload.get("success") is False:
|
|
||||||
raise ValueError("Z-Wave gateway reported failure")
|
|
||||||
|
|
||||||
result = payload.get("result")
|
|
||||||
if result is None:
|
|
||||||
result = []
|
|
||||||
if not isinstance(result, list):
|
|
||||||
raise ValueError("Malformed getNodes response: 'result' is not a list")
|
|
||||||
|
|
||||||
home_id = _resolve_home_id(result)
|
|
||||||
|
|
||||||
nodes_list: list[dict[str, Any]] = []
|
|
||||||
seen_ids: set[str] = set()
|
|
||||||
coordinator_id: str | None = None
|
|
||||||
# Map nodeId (int) → identity string, to translate neighbors → edges.
|
|
||||||
id_by_node_id: dict[Any, str] = {}
|
|
||||||
|
|
||||||
for entry in result:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
node = _node_from_zwave(entry, home_id)
|
|
||||||
if node is None or node["id"] in seen_ids:
|
|
||||||
continue
|
|
||||||
seen_ids.add(node["id"])
|
|
||||||
id_by_node_id[node["node_id"]] = node["id"]
|
|
||||||
nodes_list.append(node)
|
|
||||||
if node["type"] == "zwave_coordinator":
|
|
||||||
coordinator_id = node["id"]
|
|
||||||
|
|
||||||
# Translate neighbor lists into candidate edges (only between known nodes).
|
|
||||||
raw_edges: list[dict[str, Any]] = []
|
|
||||||
for node in nodes_list:
|
|
||||||
src = node["id"]
|
|
||||||
for neighbor in node.get("neighbors") or []:
|
|
||||||
tgt = id_by_node_id.get(neighbor)
|
|
||||||
if tgt and tgt != src:
|
|
||||||
raw_edges.append({"source": src, "target": tgt})
|
|
||||||
|
|
||||||
# Build parent_id hierarchy: coordinator → routers → end devices.
|
|
||||||
if coordinator_id:
|
|
||||||
router_ids = {n["id"] for n in nodes_list if n["type"] == "zwave_router"}
|
|
||||||
for node in nodes_list:
|
|
||||||
if node["type"] == "zwave_router":
|
|
||||||
node["parent_id"] = coordinator_id
|
|
||||||
elif node["type"] == "zwave_enddevice":
|
|
||||||
parent = _find_parent_router(node["id"], router_ids, raw_edges)
|
|
||||||
node["parent_id"] = parent or coordinator_id
|
|
||||||
|
|
||||||
# Final edges = strict parent → child tree (one edge per non-coordinator).
|
|
||||||
edges_list: list[dict[str, Any]] = [
|
|
||||||
{"source": node["parent_id"], "target": node["id"]}
|
|
||||||
for node in nodes_list
|
|
||||||
if node.get("parent_id")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Drop transient helper keys before returning.
|
|
||||||
for node in nodes_list:
|
|
||||||
node.pop("neighbors", None)
|
|
||||||
node.pop("node_id", None)
|
|
||||||
|
|
||||||
return nodes_list, edges_list
|
|
||||||
|
|
||||||
|
|
||||||
def build_zwave_properties(
|
|
||||||
ieee: str | None,
|
|
||||||
vendor: str | None,
|
|
||||||
model: str | None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""Build a NodeProperty list for a Z-Wave device (Identity, Vendor, Model).
|
|
||||||
|
|
||||||
Z-Wave has no LQI, so that row is omitted. New props default to
|
|
||||||
``visible=False`` — users opt in from the right panel.
|
|
||||||
"""
|
|
||||||
props: list[dict[str, Any]] = []
|
|
||||||
if ieee:
|
|
||||||
props.append({"key": "Z-Wave ID", "value": ieee, "icon": None, "visible": False})
|
|
||||||
if vendor:
|
|
||||||
props.append({"key": "Vendor", "value": vendor, "icon": None, "visible": False})
|
|
||||||
if model:
|
|
||||||
props.append({"key": "Model", "value": model, "icon": None, "visible": False})
|
|
||||||
return props
|
|
||||||
|
|
||||||
|
|
||||||
async def fetch_zwave_network(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
prefix: str = "zwave",
|
|
||||||
gateway_name: str = "zwavejs2mqtt",
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
||||||
"""Connect to the broker, request the Z-Wave node list, return (nodes, edges).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TimeoutError: if the gateway does not respond in time.
|
|
||||||
ConnectionError: if the broker cannot be reached.
|
|
||||||
ValueError: if the response payload is malformed.
|
|
||||||
"""
|
|
||||||
request_topic = _REQUEST_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
|
||||||
response_topic = _RESPONSE_TOPIC.format(prefix=prefix, gateway=gateway_name)
|
|
||||||
|
|
||||||
payload = await request_response(
|
|
||||||
mqtt_host=mqtt_host,
|
|
||||||
mqtt_port=mqtt_port,
|
|
||||||
request_topic=request_topic,
|
|
||||||
response_topic=response_topic,
|
|
||||||
request_payload={"args": []},
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
tls=tls,
|
|
||||||
tls_insecure=tls_insecure,
|
|
||||||
)
|
|
||||||
|
|
||||||
return parse_zwave_nodes(payload)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_zwave_connection(
|
|
||||||
mqtt_host: str,
|
|
||||||
mqtt_port: int,
|
|
||||||
username: str | None = None,
|
|
||||||
password: str | None = None,
|
|
||||||
tls: bool = False,
|
|
||||||
tls_insecure: bool = False,
|
|
||||||
) -> bool:
|
|
||||||
"""Quick MQTT reachability check for the Z-Wave broker."""
|
|
||||||
return await test_connection(
|
|
||||||
mqtt_host=mqtt_host,
|
|
||||||
mqtt_port=mqtt_port,
|
|
||||||
username=username,
|
|
||||||
password=password,
|
|
||||||
tls=tls,
|
|
||||||
tls_insecure=tls_insecure,
|
|
||||||
)
|
|
||||||
@@ -2,4 +2,3 @@
|
|||||||
*.db-shm
|
*.db-shm
|
||||||
*.db-wal
|
*.db-wal
|
||||||
scan_config.json
|
scan_config.json
|
||||||
homelab.db.*
|
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
[
|
||||||
|
{"port": 8006, "protocol": "tcp", "banner_regex": null, "service_name": "Proxmox VE", "icon": "layers", "category": "hypervisor", "suggested_node_type": "proxmox"},
|
||||||
|
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "synology|DSM", "service_name": "Synology DSM", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5001, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM HTTPS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5006, "protocol": "tcp", "banner_regex": null, "service_name": "Synology DSM Mobile", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "QNAP|qnap|QTS", "service_name": "QNAP NAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 5005, "protocol": "tcp", "banner_regex": null, "service_name": "TrueNAS", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
|
||||||
|
{"port": 445, "protocol": "tcp", "banner_regex": null, "service_name": "SMB / CIFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 2049, "protocol": "tcp", "banner_regex": null, "service_name": "NFS", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 548, "protocol": "tcp", "banner_regex": null, "service_name": "AFP (Apple Filing)", "icon": "share-2", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
{"port": 873, "protocol": "tcp", "banner_regex": null, "service_name": "rsync", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "nas"},
|
||||||
|
|
||||||
|
{"port": 32400, "protocol": "tcp", "banner_regex": null, "service_name": "Plex Media Server", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 32469, "protocol": "tcp", "banner_regex": null, "service_name": "Plex DLNA", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Jellyfin", "service_name": "Jellyfin", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": "Emby", "service_name": "Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8096, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin / Emby", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8920, "protocol": "tcp", "banner_regex": null, "service_name": "Jellyfin HTTPS", "icon": "play-circle", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8181, "protocol": "tcp", "banner_regex": null, "service_name": "Tautulli", "icon": "bar-chart", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8013, "protocol": "tcp", "banner_regex": null, "service_name": "Komga", "icon": "book-open", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 1935, "protocol": "tcp", "banner_regex": null, "service_name": "RTMP (Stream)", "icon": "video", "category": "media", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8989, "protocol": "tcp", "banner_regex": null, "service_name": "Sonarr", "icon": "tv", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 7878, "protocol": "tcp", "banner_regex": null, "service_name": "Radarr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8686, "protocol": "tcp", "banner_regex": null, "service_name": "Lidarr", "icon": "music", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9696, "protocol": "tcp", "banner_regex": null, "service_name": "Prowlarr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8787, "protocol": "tcp", "banner_regex": null, "service_name": "Readarr", "icon": "book", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6767, "protocol": "tcp", "banner_regex": null, "service_name": "Bazarr", "icon": "subtitles", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5055, "protocol": "tcp", "banner_regex": null, "service_name": "Overseerr / Jellyseerr", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 9117, "protocol": "tcp", "banner_regex": null, "service_name": "Jackett", "icon": "search", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 6969, "protocol": "tcp", "banner_regex": null, "service_name": "Whisparr", "icon": "film", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 5454, "protocol": "tcp", "banner_regex": null, "service_name": "Notifiarr", "icon": "bell", "category": "media", "suggested_node_type": "server"},
|
||||||
|
{"port": 8191, "protocol": "tcp", "banner_regex": null, "service_name": "FlareSolverr", "icon": "shield", "category": "network", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "Transmission", "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 8112, "protocol": "tcp", "banner_regex": null, "service_name": "Deluge", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6789, "protocol": "tcp", "banner_regex": null, "service_name": "NZBGet", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6800, "protocol": "tcp", "banner_regex": null, "service_name": "Aria2 RPC", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 51413, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission BitTorrent", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 6881, "protocol": "tcp", "banner_regex": null, "service_name": "BitTorrent Peer", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8123, "protocol": "tcp", "banner_regex": null, "service_name": "Home Assistant", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8883, "protocol": "tcp", "banner_regex": null, "service_name": "MQTT Broker TLS", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 6052, "protocol": "tcp", "banner_regex": null, "service_name": "ESPHome", "icon": "cpu", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
{"port": 1880, "protocol": "tcp", "banner_regex": null, "service_name": "Node-RED", "icon": "git-branch", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8971, "protocol": "tcp", "banner_regex": null, "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 10443, "protocol": "tcp", "banner_regex": null, "service_name": "Scrypted", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": "frigate", "service_name": "Frigate NVR", "icon": "camera", "category": "nvr", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8081, "protocol": "tcp", "banner_regex": "iobroker|ioBroker", "service_name": "ioBroker", "icon": "cpu", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "Domoticz|domoticz", "service_name": "Domoticz", "icon": "home", "category": "automation", "suggested_node_type": "iot"},
|
||||||
|
{"port": 5683, "protocol": "udp", "banner_regex": null, "service_name": "CoAP (IoT)", "icon": "radio", "category": "iot", "suggested_node_type": "iot"},
|
||||||
|
|
||||||
|
{"port": 554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8554, "protocol": "tcp", "banner_regex": null, "service_name": "RTSP Alt (Camera)", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 37777, "protocol": "tcp", "banner_regex": null, "service_name": "Dahua Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 34567, "protocol": "tcp", "banner_regex": null, "service_name": "Amcrest / Dahua Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 8000, "protocol": "tcp", "banner_regex": "[Hh]ikvision|[Dd]ahua", "service_name": "IP Camera SDK", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 2020, "protocol": "tcp", "banner_regex": null, "service_name": "TP-Link Tapo Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Rr]eolink", "service_name": "Reolink Camera", "icon": "camera", "category": "camera", "suggested_node_type": "camera"},
|
||||||
|
|
||||||
|
{"port": 8291, "protocol": "tcp", "banner_regex": null, "service_name": "MikroTik Winbox", "icon": "router", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 8880, "protocol": "tcp", "banner_regex": null, "service_name": "UniFi HTTP Portal", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": "[Uu]ni[Ff]i", "service_name": "UniFi Controller", "icon": "wifi", "category": "network", "suggested_node_type": "ap"},
|
||||||
|
{"port": 4711, "protocol": "tcp", "banner_regex": null, "service_name": "Pi-hole API", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Aa]d[Gg]uard", "service_name": "AdGuard Home", "icon": "shield", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 81, "protocol": "tcp", "banner_regex": null, "service_name": "Nginx Proxy Manager", "icon": "arrow-right", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 23, "protocol": "tcp", "banner_regex": null, "service_name": "Telnet", "icon": "terminal", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
{"port": 161, "protocol": "udp", "banner_regex": null, "service_name": "SNMP", "icon": "activity", "category": "network", "suggested_node_type": "switch"},
|
||||||
|
|
||||||
|
{"port": 8200, "protocol": "tcp", "banner_regex": null, "service_name": "HashiCorp Vault", "icon": "lock", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 389, "protocol": "tcp", "banner_regex": null, "service_name": "LDAP", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 636, "protocol": "tcp", "banner_regex": null, "service_name": "LDAPS", "icon": "users", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": "[Aa]uthelia", "service_name": "Authelia", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Aa]uthentik", "service_name": "Authentik", "icon": "shield", "category": "security", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": "[Kk]eycloak", "service_name": "Keycloak", "icon": "shield", "category": "auth", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]rafana", "service_name": "Grafana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9090, "protocol": "tcp", "banner_regex": null, "service_name": "Prometheus", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9093, "protocol": "tcp", "banner_regex": null, "service_name": "Alertmanager", "icon": "bell", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9100, "protocol": "tcp", "banner_regex": null, "service_name": "Node Exporter", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8086, "protocol": "tcp", "banner_regex": null, "service_name": "InfluxDB", "icon": "database", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3100, "protocol": "tcp", "banner_regex": null, "service_name": "Grafana Loki", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8428, "protocol": "tcp", "banner_regex": null, "service_name": "VictoriaMetrics", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 19999, "protocol": "tcp", "banner_regex": null, "service_name": "Netdata", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 3001, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 8581, "protocol": "tcp", "banner_regex": null, "service_name": "Uptime Kuma", "icon": "heart", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 10051, "protocol": "tcp", "banner_regex": null, "service_name": "Zabbix Server", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 9411, "protocol": "tcp", "banner_regex": null, "service_name": "Zipkin", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 16686, "protocol": "tcp", "banner_regex": null, "service_name": "Jaeger UI", "icon": "activity", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
{"port": 5601, "protocol": "tcp", "banner_regex": null, "service_name": "Kibana", "icon": "bar-chart-2", "category": "monitoring", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer HTTPS", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": "[Pp]ortainer", "service_name": "Portainer", "icon": "box", "category": "containers", "suggested_node_type": "lxc"},
|
||||||
|
{"port": 2375, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 2376, "protocol": "tcp", "banner_regex": null, "service_name": "Docker API TLS", "icon": "box", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
{"port": 6443, "protocol": "tcp", "banner_regex": null, "service_name": "Kubernetes API", "icon": "layers", "category": "containers", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 3306, "protocol": "tcp", "banner_regex": null, "service_name": "MySQL / MariaDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5432, "protocol": "tcp", "banner_regex": null, "service_name": "PostgreSQL", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6379, "protocol": "tcp", "banner_regex": null, "service_name": "Redis", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 27017, "protocol": "tcp", "banner_regex": null, "service_name": "MongoDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9200, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 9300, "protocol": "tcp", "banner_regex": null, "service_name": "Elasticsearch Transport", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 5984, "protocol": "tcp", "banner_regex": null, "service_name": "CouchDB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 1521, "protocol": "tcp", "banner_regex": null, "service_name": "Oracle DB", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
{"port": 6432, "protocol": "tcp", "banner_regex": null, "service_name": "PgBouncer", "icon": "database", "category": "database", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 22, "protocol": "tcp", "banner_regex": null, "service_name": "SSH", "icon": "terminal", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 21, "protocol": "tcp", "banner_regex": null, "service_name": "FTP", "icon": "upload", "category": "storage", "suggested_node_type": "server"},
|
||||||
|
{"port": 25, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 110, "protocol": "tcp", "banner_regex": null, "service_name": "POP3", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 143, "protocol": "tcp", "banner_regex": null, "service_name": "IMAP", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 465, "protocol": "tcp", "banner_regex": null, "service_name": "SMTPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 587, "protocol": "tcp", "banner_regex": null, "service_name": "SMTP Submission", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 993, "protocol": "tcp", "banner_regex": null, "service_name": "IMAPS", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 995, "protocol": "tcp", "banner_regex": null, "service_name": "POP3S", "icon": "mail", "category": "mail", "suggested_node_type": "server"},
|
||||||
|
{"port": 3389, "protocol": "tcp", "banner_regex": null, "service_name": "RDP", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5900, "protocol": "tcp", "banner_regex": null, "service_name": "VNC", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
{"port": 5800, "protocol": "tcp", "banner_regex": null, "service_name": "VNC (HTTP)", "icon": "monitor", "category": "remote", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8888, "protocol": "tcp", "banner_regex": null, "service_name": "Jupyter Notebook", "icon": "code", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": "[Gg]itea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 80, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8080, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 8008, "protocol": "tcp", "banner_regex": null, "service_name": "HTTP Alt", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 3000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9091, "protocol": "tcp", "banner_regex": null, "service_name": "Transmission", "icon": "download", "category": "download", "suggested_node_type": "server"},
|
||||||
|
{"port": 9000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 9443, "protocol": "tcp", "banner_regex": null, "service_name": "HTTPS Alt", "icon": "lock", "category": "web", "suggested_node_type": "server"},
|
||||||
|
{"port": 5000, "protocol": "tcp", "banner_regex": null, "service_name": "Web service", "icon": "globe", "category": "web", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 8448, "protocol": "tcp", "banner_regex": null, "service_name": "Matrix (Synapse)", "icon": "message-square", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 64738, "protocol": "tcp", "banner_regex": null, "service_name": "Mumble", "icon": "mic", "category": "communication", "suggested_node_type": "server"},
|
||||||
|
{"port": 25565, "protocol": "tcp", "banner_regex": null, "service_name": "Minecraft Server", "icon": "cpu", "category": "gaming", "suggested_node_type": "server"},
|
||||||
|
|
||||||
|
{"port": 51820, "protocol": "udp", "banner_regex": null, "service_name": "WireGuard", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 1194, "protocol": "udp", "banner_regex": null, "service_name": "OpenVPN", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 500, "protocol": "udp", "banner_regex": null, "service_name": "IPsec IKE", "icon": "shield", "category": "vpn", "suggested_node_type": "router"},
|
||||||
|
{"port": 53, "protocol": "udp", "banner_regex": null, "service_name": "DNS", "icon": "search", "category": "network", "suggested_node_type": "router"},
|
||||||
|
{"port": 67, "protocol": "udp", "banner_regex": null, "service_name": "DHCP", "icon": "wifi", "category": "network", "suggested_node_type": "router"}
|
||||||
|
]
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
@@ -25,8 +25,6 @@ addopts = "--tb=short -q"
|
|||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = ["app"]
|
source = ["app"]
|
||||||
omit = ["*/migrations/*", "*/tests/*"]
|
omit = ["*/migrations/*", "*/tests/*"]
|
||||||
concurrency = ["thread"]
|
|
||||||
core = "sysmon"
|
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
skip_empty = true
|
skip_empty = true
|
||||||
|
|||||||
@@ -7,20 +7,19 @@ alembic==1.13.3
|
|||||||
pydantic==2.9.2
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.5.2
|
pydantic-settings==2.5.2
|
||||||
python-jose[cryptography]==3.5.0
|
python-jose[cryptography]==3.5.0
|
||||||
bcrypt==4.2.1
|
passlib[bcrypt]==1.7.4
|
||||||
python-multipart==0.0.31
|
bcrypt==4.0.1
|
||||||
|
python-multipart==0.0.22
|
||||||
apscheduler==3.10.4
|
apscheduler==3.10.4
|
||||||
python-nmap==0.7.1
|
python-nmap==0.7.1
|
||||||
pyyaml==6.0.2
|
pyyaml==6.0.2
|
||||||
types-PyYAML==6.0.12.20240917
|
types-PyYAML==6.0.12.20240917
|
||||||
websockets==13.1
|
websockets==13.1
|
||||||
httpx==0.27.2
|
httpx==0.27.2
|
||||||
zeroconf==0.149.16
|
|
||||||
aiomqtt==2.3.0
|
|
||||||
|
|
||||||
# Dev
|
# Dev
|
||||||
ruff==0.6.9
|
ruff==0.6.9
|
||||||
mypy==1.11.2
|
mypy==1.11.2
|
||||||
pytest==9.0.3
|
pytest==8.3.3
|
||||||
pytest-asyncio==1.3.0
|
pytest-asyncio==0.24.0
|
||||||
pytest-cov==5.0.0
|
pytest-cov==5.0.0
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Generate a bcrypt password hash for the AUTH_PASSWORD_HASH env var."""
|
"""Generate a bcrypt password hash for config.yml."""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import bcrypt
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
if len(sys.argv) < 2:
|
if len(sys.argv) < 2:
|
||||||
print("Usage: python scripts/hash_password.py <password>")
|
print("Usage: python scripts/hash_password.py <password>")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
password = sys.argv[1]
|
password = sys.argv[1]
|
||||||
print(bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8"))
|
print(pwd_context.hash(password))
|
||||||
|
|||||||
@@ -5,21 +5,23 @@ os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
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 Base, get_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
||||||
|
|
||||||
|
_pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True, scope="session")
|
@pytest.fixture(autouse=True, scope="session")
|
||||||
def test_credentials():
|
def test_credentials():
|
||||||
"""Configure test auth credentials directly on settings."""
|
"""Configure test auth credentials directly on settings."""
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
settings.auth_username = "admin"
|
settings.auth_username = "admin"
|
||||||
settings.auth_password_hash = hash_password("admin")
|
settings.auth_password_hash = _pwd_ctx.hash("admin")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -44,7 +46,10 @@ async def client(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def headers(client: AsyncClient):
|
def auth_headers(client):
|
||||||
"""Authenticated Bearer headers for the default admin test user."""
|
"""Returns a coroutine that logs in and returns auth headers."""
|
||||||
|
async def _get():
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
return _get
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for automatic DB backup before migrations.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.db.database import _backup_db
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def tmp_db(tmp_path: Path):
|
|
||||||
db = tmp_path / "homelab.db"
|
|
||||||
db.write_bytes(b"SQLite placeholder")
|
|
||||||
return db
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_created_when_db_exists(tmp_db: Path):
|
|
||||||
with patch("app.db.database.settings") as mock_settings, \
|
|
||||||
patch("app.db.database.APP_VERSION", "1.9"):
|
|
||||||
mock_settings.sqlite_path = str(tmp_db)
|
|
||||||
_backup_db()
|
|
||||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
|
||||||
assert backup.exists()
|
|
||||||
assert backup.read_bytes() == b"SQLite placeholder"
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_skipped_when_db_missing(tmp_path: Path):
|
|
||||||
with patch("app.db.database.settings") as mock_settings, \
|
|
||||||
patch("app.db.database.APP_VERSION", "1.9"):
|
|
||||||
mock_settings.sqlite_path = str(tmp_path / "nonexistent.db")
|
|
||||||
_backup_db()
|
|
||||||
assert not any(tmp_path.glob("*.back-*"))
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_idempotent_second_call_no_overwrite(tmp_db: Path):
|
|
||||||
with patch("app.db.database.settings") as mock_settings, \
|
|
||||||
patch("app.db.database.APP_VERSION", "1.9"):
|
|
||||||
mock_settings.sqlite_path = str(tmp_db)
|
|
||||||
_backup_db()
|
|
||||||
backup = tmp_db.parent / "homelab.db.back-1.9"
|
|
||||||
backup.write_bytes(b"original backup")
|
|
||||||
_backup_db()
|
|
||||||
assert backup.read_bytes() == b"original backup"
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_version_in_filename(tmp_db: Path):
|
|
||||||
with patch("app.db.database.settings") as mock_settings, \
|
|
||||||
patch("app.db.database.APP_VERSION", "2.0"):
|
|
||||||
mock_settings.sqlite_path = str(tmp_db)
|
|
||||||
_backup_db()
|
|
||||||
assert (tmp_db.parent / "homelab.db.back-2.0").exists()
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
"""Backward-compatibility tests for the legacy → multi-design migration.
|
|
||||||
|
|
||||||
Simulates a database created by a pre-"designs" version of the app and asserts
|
|
||||||
that running init_db() adopts all existing nodes/edges/canvas into a single
|
|
||||||
default "Network Topology" design with no data loss. The rest of the test suite
|
|
||||||
builds the *current* schema via create_all and never exercises this upgrade
|
|
||||||
path, so this file guards real users upgrading in place.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
import app.db.database as database
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def legacy_engine(tmp_path, monkeypatch):
|
|
||||||
"""Point the module-global engine + sqlite_path at a throwaway legacy DB."""
|
|
||||||
db_path = tmp_path / "legacy.db"
|
|
||||||
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
|
|
||||||
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
|
||||||
monkeypatch.setattr(database, "engine", engine)
|
|
||||||
return db_path, engine
|
|
||||||
|
|
||||||
|
|
||||||
async def _build_legacy_schema(engine) -> None:
|
|
||||||
"""Create the pre-designs schema (no design_id, integer canvas_state PK)."""
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE TABLE nodes (id VARCHAR PRIMARY KEY, type VARCHAR, label VARCHAR, "
|
|
||||||
"status VARCHAR, services JSON, pos_x FLOAT, pos_y FLOAT)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE TABLE edges (id VARCHAR PRIMARY KEY, source VARCHAR, target VARCHAR, type VARCHAR)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"CREATE TABLE canvas_state (id INTEGER PRIMARY KEY, viewport JSON, "
|
|
||||||
"custom_style JSON, saved_at DATETIME)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
|
||||||
"VALUES ('n1','server','Old Server','online','[]',10,20)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, type, label, status, services, pos_x, pos_y) "
|
|
||||||
"VALUES ('n2','router','Old Router','offline','[]',30,40)"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO edges (id, source, target, type) VALUES ('e1','n1','n2','ethernet')"
|
|
||||||
)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO canvas_state (id, viewport, custom_style, saved_at) "
|
|
||||||
"VALUES (1, '{\"x\":5,\"y\":6,\"zoom\":2}', NULL, '2024-01-01 00:00:00')"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_canvas_migrates_into_default_design(legacy_engine):
|
|
||||||
db_path, engine = legacy_engine
|
|
||||||
await _build_legacy_schema(engine)
|
|
||||||
|
|
||||||
await database.init_db()
|
|
||||||
|
|
||||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
|
||||||
try:
|
|
||||||
async with check.begin() as conn:
|
|
||||||
# Exactly one seeded default design.
|
|
||||||
designs = (await conn.exec_driver_sql(
|
|
||||||
"SELECT id, name, design_type, icon FROM designs"
|
|
||||||
)).fetchall()
|
|
||||||
assert len(designs) == 1
|
|
||||||
did, name, dtype, icon = designs[0]
|
|
||||||
assert name == "Network Topology"
|
|
||||||
assert dtype == "network"
|
|
||||||
assert icon == "dashboard"
|
|
||||||
|
|
||||||
# Every legacy node adopted into the default design, data preserved.
|
|
||||||
nodes = (await conn.exec_driver_sql(
|
|
||||||
"SELECT id, label, status, design_id FROM nodes ORDER BY id"
|
|
||||||
)).fetchall()
|
|
||||||
assert [(n[0], n[1], n[2]) for n in nodes] == [
|
|
||||||
("n1", "Old Server", "online"),
|
|
||||||
("n2", "Old Router", "offline"),
|
|
||||||
]
|
|
||||||
assert all(n[3] == did for n in nodes)
|
|
||||||
|
|
||||||
# Legacy edge adopted too.
|
|
||||||
edge = (await conn.exec_driver_sql(
|
|
||||||
"SELECT design_id FROM edges WHERE id='e1'"
|
|
||||||
)).fetchone()
|
|
||||||
assert edge[0] == did
|
|
||||||
|
|
||||||
# canvas_state rebuilt with design_id PK; the old id=1 row maps to the
|
|
||||||
# default design and the viewport survives.
|
|
||||||
cs = (await conn.exec_driver_sql(
|
|
||||||
"SELECT design_id, viewport FROM canvas_state"
|
|
||||||
)).fetchall()
|
|
||||||
assert len(cs) == 1
|
|
||||||
assert cs[0][0] == did
|
|
||||||
assert "zoom" in (cs[0][1] or "")
|
|
||||||
finally:
|
|
||||||
await check.dispose()
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_nodes_gain_last_scan_column(legacy_engine):
|
|
||||||
"""A legacy nodes table (no last_scan) gains the column after init_db."""
|
|
||||||
db_path, engine = legacy_engine
|
|
||||||
await _build_legacy_schema(engine)
|
|
||||||
|
|
||||||
await database.init_db()
|
|
||||||
|
|
||||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
|
||||||
try:
|
|
||||||
async with check.begin() as conn:
|
|
||||||
cols = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
|
|
||||||
assert "last_scan" in {c[1] for c in cols}
|
|
||||||
# Existing rows backfill to NULL (never scanned yet).
|
|
||||||
last_scan = (await conn.exec_driver_sql(
|
|
||||||
"SELECT last_scan FROM nodes WHERE id='n1'"
|
|
||||||
)).fetchone()
|
|
||||||
assert last_scan[0] is None
|
|
||||||
finally:
|
|
||||||
await check.dispose()
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_migration_is_idempotent(legacy_engine):
|
|
||||||
"""Running init_db twice must not duplicate the design or drop any data."""
|
|
||||||
db_path, engine = legacy_engine
|
|
||||||
await _build_legacy_schema(engine)
|
|
||||||
|
|
||||||
await database.init_db()
|
|
||||||
await database.init_db() # second boot — should be a no-op
|
|
||||||
|
|
||||||
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
|
|
||||||
try:
|
|
||||||
async with check.begin() as conn:
|
|
||||||
designs = (await conn.exec_driver_sql("SELECT id FROM designs")).fetchall()
|
|
||||||
assert len(designs) == 1
|
|
||||||
did = designs[0][0]
|
|
||||||
|
|
||||||
nodes = (await conn.exec_driver_sql(
|
|
||||||
"SELECT design_id FROM nodes"
|
|
||||||
)).fetchall()
|
|
||||||
assert len(nodes) == 2
|
|
||||||
assert all(n[0] == did for n in nodes)
|
|
||||||
|
|
||||||
cs = (await conn.exec_driver_sql("SELECT design_id FROM canvas_state")).fetchall()
|
|
||||||
assert len(cs) == 1
|
|
||||||
assert cs[0][0] == did
|
|
||||||
finally:
|
|
||||||
await check.dispose()
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the hardware → properties migration logic.
|
|
||||||
|
|
||||||
We test the migration function directly against an in-memory SQLite database
|
|
||||||
so we can set up legacy rows (with hardware columns, NULL properties) and
|
|
||||||
verify the migration produces the expected properties JSON.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
|
|
||||||
TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup_legacy_table(conn):
|
|
||||||
"""Create a minimal nodes table that mimics the pre-migration schema."""
|
|
||||||
await conn.exec_driver_sql("""
|
|
||||||
CREATE TABLE IF NOT EXISTS nodes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL DEFAULT 'generic',
|
|
||||||
label TEXT NOT NULL DEFAULT '',
|
|
||||||
cpu_model TEXT,
|
|
||||||
cpu_count INTEGER,
|
|
||||||
ram_gb REAL,
|
|
||||||
disk_gb REAL,
|
|
||||||
show_hardware BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
properties JSON
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_migration(conn):
|
|
||||||
"""Run only the properties migration portion (extracted from init_db)."""
|
|
||||||
rows = await conn.exec_driver_sql(
|
|
||||||
"SELECT id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware "
|
|
||||||
"FROM nodes WHERE properties IS NULL"
|
|
||||||
)
|
|
||||||
for row in rows.fetchall():
|
|
||||||
node_id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware = row
|
|
||||||
props = []
|
|
||||||
visible = bool(show_hardware)
|
|
||||||
if cpu_model:
|
|
||||||
props.append({"key": "CPU Model", "value": str(cpu_model), "icon": "Cpu", "visible": visible})
|
|
||||||
if cpu_count is not None:
|
|
||||||
props.append({"key": "CPU Cores", "value": str(cpu_count), "icon": "Cpu", "visible": visible})
|
|
||||||
if ram_gb is not None:
|
|
||||||
props.append({"key": "RAM", "value": f"{ram_gb} GB", "icon": "MemoryStick", "visible": visible})
|
|
||||||
if disk_gb is not None:
|
|
||||||
props.append({"key": "Disk", "value": f"{disk_gb} GB", "icon": "HardDrive", "visible": visible})
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"UPDATE nodes SET properties = ? WHERE id = ?",
|
|
||||||
(json.dumps(props), node_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_properties(conn, node_id: str) -> list:
|
|
||||||
rows = await conn.exec_driver_sql("SELECT properties FROM nodes WHERE id = ?", (node_id,))
|
|
||||||
raw = rows.fetchone()[0]
|
|
||||||
return json.loads(raw) if raw else []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_full_hardware():
|
|
||||||
"""Node with all 4 hardware fields → 4 property entries with correct icons."""
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, cpu_model, cpu_count, ram_gb, disk_gb, show_hardware) "
|
|
||||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
||||||
("node-1", "i7-12700K", 12, 32.0, 2000.0, 1),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
props = await _get_properties(conn, "node-1")
|
|
||||||
|
|
||||||
assert len(props) == 4
|
|
||||||
assert props[0] == {"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True}
|
|
||||||
assert props[1] == {"key": "CPU Cores", "value": "12", "icon": "Cpu", "visible": True}
|
|
||||||
assert props[2] == {"key": "RAM", "value": "32.0 GB", "icon": "MemoryStick", "visible": True}
|
|
||||||
assert props[3] == {"key": "Disk", "value": "2000.0 GB", "icon": "HardDrive", "visible": True}
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_partial_hardware():
|
|
||||||
"""Node with only cpu_model and ram_gb → 2 property entries."""
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
|
||||||
("node-2", "Ryzen 5 5600", 16.0, 0),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
props = await _get_properties(conn, "node-2")
|
|
||||||
|
|
||||||
assert len(props) == 2
|
|
||||||
assert props[0]["key"] == "CPU Model"
|
|
||||||
assert props[0]["visible"] is False
|
|
||||||
assert props[1]["key"] == "RAM"
|
|
||||||
assert props[1]["icon"] == "MemoryStick"
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_no_hardware():
|
|
||||||
"""Node with no hardware fields → empty properties array."""
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id) VALUES (?)",
|
|
||||||
("node-3",),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
props = await _get_properties(conn, "node-3")
|
|
||||||
|
|
||||||
assert props == []
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_idempotent():
|
|
||||||
"""Running migration twice does not duplicate properties."""
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, cpu_model, show_hardware) VALUES (?, ?, ?)",
|
|
||||||
("node-4", "Core i5", 1),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
await _run_migration(conn) # second pass — node already has properties, should be skipped
|
|
||||||
props = await _get_properties(conn, "node-4")
|
|
||||||
|
|
||||||
assert len(props) == 1
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_show_hardware_false_sets_visible_false():
|
|
||||||
"""show_hardware=0 means all migrated properties have visible=False."""
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware) VALUES (?, ?, ?, ?)",
|
|
||||||
("node-5", "ARM Cortex-A72", 4.0, 0),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
props = await _get_properties(conn, "node-5")
|
|
||||||
|
|
||||||
assert all(p["visible"] is False for p in props)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_migration_already_migrated_node_not_touched():
|
|
||||||
"""Node that already has properties is skipped — existing properties preserved."""
|
|
||||||
existing = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
|
||||||
engine = create_async_engine(TEST_DB_URL)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await _setup_legacy_table(conn)
|
|
||||||
await conn.exec_driver_sql(
|
|
||||||
"INSERT INTO nodes (id, cpu_model, ram_gb, show_hardware, properties) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
("node-6", "i9-13900K", 64.0, 1, json.dumps(existing)),
|
|
||||||
)
|
|
||||||
await _run_migration(conn)
|
|
||||||
props = await _get_properties(conn, "node-6")
|
|
||||||
|
|
||||||
assert props == existing
|
|
||||||
await engine.dispose()
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""Fixtures shared across the scan test modules."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def pending_device(db_session):
|
|
||||||
import uuid
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip="192.168.1.100",
|
|
||||||
mac="aa:bb:cc:dd:ee:ff",
|
|
||||||
hostname="my-server",
|
|
||||||
os="Linux",
|
|
||||||
services=[{"port": 22, "name": "ssh"}],
|
|
||||||
suggested_type="server",
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(device)
|
|
||||||
return device
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def mem_db():
|
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
|
|
||||||
from app.db.database import Base
|
|
||||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
yield factory
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def two_pending_devices(db_session):
|
|
||||||
devices = []
|
|
||||||
for i in range(2):
|
|
||||||
d = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip=f"192.168.1.{10 + i}",
|
|
||||||
mac=None,
|
|
||||||
hostname=f"host-{i}",
|
|
||||||
os=None,
|
|
||||||
services=[],
|
|
||||||
suggested_type="generic",
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(d)
|
|
||||||
devices.append(d)
|
|
||||||
await db_session.commit()
|
|
||||||
for d in devices:
|
|
||||||
await db_session.refresh(d)
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def zigbee_pending_device(db_session):
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip=None,
|
|
||||||
mac=None,
|
|
||||||
hostname=None,
|
|
||||||
friendly_name="bulb_1",
|
|
||||||
services=[],
|
|
||||||
suggested_type="zigbee_enddevice",
|
|
||||||
device_subtype="EndDevice",
|
|
||||||
ieee_address="0xABCDEF",
|
|
||||||
vendor="IKEA",
|
|
||||||
model="TRADFRI",
|
|
||||||
lqi=180,
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
await db_session.refresh(device)
|
|
||||||
return device
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
"""Shared builders for scan test suite (pure helpers, no fixtures)."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.db.models import Design, Node, PendingDevice
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_design(db_session, name: str) -> str:
|
|
||||||
design = Design(id=str(uuid.uuid4()), name=name)
|
|
||||||
db_session.add(design)
|
|
||||||
await db_session.commit()
|
|
||||||
return design.id
|
|
||||||
|
|
||||||
|
|
||||||
def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
|
|
||||||
return Node(
|
|
||||||
id=str(uuid.uuid4()), label="n", type="server", status="online",
|
|
||||||
ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
|
|
||||||
design_id=design_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _seed_zigbee_pending_pair(db_session):
|
|
||||||
"""Create a coordinator Node + a pending device + a link between them."""
|
|
||||||
from app.db.models import Node, PendingDeviceLink
|
|
||||||
|
|
||||||
coord = Node(
|
|
||||||
label="Coordinator",
|
|
||||||
type="zigbee_coordinator",
|
|
||||||
status="unknown",
|
|
||||||
ieee_address="0xCOORD",
|
|
||||||
)
|
|
||||||
db_session.add(coord)
|
|
||||||
|
|
||||||
pending = PendingDevice(
|
|
||||||
ieee_address="0xR1",
|
|
||||||
friendly_name="router_1",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(pending)
|
|
||||||
|
|
||||||
db_session.add(
|
|
||||||
PendingDeviceLink(
|
|
||||||
source_ieee="0xCOORD",
|
|
||||||
target_ieee="0xR1",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
return coord, pending
|
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
"""Approve / hide / restore / ignore / bulk device flows and conflict handling."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Design, Node, PendingDevice
|
|
||||||
from tests.scan.helpers import _add_design, _node
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
|
|
||||||
# A node with no design_id is not "on a canvas".
|
|
||||||
db_session.add(_node(None, ip="192.168.1.100"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert res.json()[0]["canvas_count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
|
||||||
node_payload = {
|
|
||||||
"label": "My Server",
|
|
||||||
"type": "server",
|
|
||||||
"ip": "192.168.1.100",
|
|
||||||
"hostname": "my-server",
|
|
||||||
"status": "unknown",
|
|
||||||
"services": [],
|
|
||||||
}
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json=node_payload,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] is True
|
|
||||||
assert "node_id" in data
|
|
||||||
|
|
||||||
# Approved devices stay in the inventory (status != "hidden") so they keep
|
|
||||||
# showing with an "In N canvas" badge — they are no longer dropped.
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
inventory = pending_res.json()
|
|
||||||
assert len(inventory) == 1
|
|
||||||
assert inventory[0]["id"] == pending_device.id
|
|
||||||
assert inventory[0]["status"] == "approved"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_conflicts_on_existing_ieee_same_design(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""Approving a device whose IEEE is already on the target design prompts the
|
|
||||||
user (409) instead of silently merging/replacing — same UX as ip/mac."""
|
|
||||||
design = Design(name="d1")
|
|
||||||
db_session.add(design)
|
|
||||||
await db_session.flush()
|
|
||||||
existing = Node(
|
|
||||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
|
||||||
services=[], design_id=design.id,
|
|
||||||
)
|
|
||||||
db_session.add(existing)
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
|
||||||
status="pending", discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{device.id}/approve",
|
|
||||||
json={
|
|
||||||
"label": "sensor", "type": "zigbee_enddevice",
|
|
||||||
"status": "online", "services": [], "design_id": design.id,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 409
|
|
||||||
detail = res.json()["detail"]
|
|
||||||
assert detail["duplicate"] is True
|
|
||||||
assert detail["existing_node_id"] == existing.id
|
|
||||||
assert detail["match"] == "ieee"
|
|
||||||
assert detail["value"] == "0xZZZ"
|
|
||||||
|
|
||||||
# No second node created; device stays pending until the user decides.
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
|
||||||
).scalars().all()
|
|
||||||
assert len(nodes) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_force_creates_duplicate_ieee(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""force=True lets the user place a second card for the same IEEE."""
|
|
||||||
design = Design(name="d1")
|
|
||||||
db_session.add(design)
|
|
||||||
await db_session.flush()
|
|
||||||
db_session.add(Node(
|
|
||||||
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
|
|
||||||
services=[], design_id=design.id,
|
|
||||||
))
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
|
|
||||||
status="pending", discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{device.id}/approve",
|
|
||||||
json={
|
|
||||||
"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
|
||||||
"services": [], "design_id": design.id, "force": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
|
|
||||||
).scalars().all()
|
|
||||||
assert len(nodes) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_conflicts_on_existing_ip(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""An ordinary host whose ip already sits on the target design is NOT
|
|
||||||
silently duplicated: the approve returns 409 with the existing node so the
|
|
||||||
UI can ask the user."""
|
|
||||||
design = await _add_design(db_session, "Home")
|
|
||||||
existing = _node(design, ip="192.168.1.100")
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
||||||
"status": "unknown", "services": [], "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 409
|
|
||||||
detail = res.json()["detail"]
|
|
||||||
assert detail["duplicate"] is True
|
|
||||||
assert detail["existing_node_id"] == existing.id
|
|
||||||
assert detail["match"] == "ip"
|
|
||||||
assert detail["value"] == "192.168.1.100"
|
|
||||||
|
|
||||||
# No node created, device left pending (user hasn't decided yet).
|
|
||||||
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
|
||||||
assert len(nodes) == 1
|
|
||||||
await db_session.refresh(pending_device)
|
|
||||||
assert pending_device.status == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_conflicts_on_existing_mac(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
|
|
||||||
design = await _add_design(db_session, "Home")
|
|
||||||
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
|
|
||||||
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "dup", "type": "server", "ip": "192.168.1.55",
|
|
||||||
"mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [],
|
|
||||||
"design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 409
|
|
||||||
assert res.json()["detail"]["match"] == "mac"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_conflicts_on_ip_in_comma_list(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""The existing node's ip holds an IPv6 before the IPv4 the device scanned
|
|
||||||
as. Exact-string matching missed it (issue #258); per-token matching catches
|
|
||||||
the duplicate."""
|
|
||||||
design = await _add_design(db_session, "Home")
|
|
||||||
existing = _node(design, ip="fe80::1, 192.168.1.100")
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
||||||
"status": "unknown", "services": [], "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 409
|
|
||||||
detail = res.json()["detail"]
|
|
||||||
assert detail["existing_node_id"] == existing.id
|
|
||||||
assert detail["match"] == "ip"
|
|
||||||
assert detail["value"] == "192.168.1.100"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_no_conflict_on_ip_substring(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""The ip guard must match whole addresses, not substrings: a node at
|
|
||||||
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
|
|
||||||
design = await _add_design(db_session, "Home")
|
|
||||||
db_session.add(_node(design, ip="10.0.0.40"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "new", "type": "server", "ip": "10.0.0.4",
|
|
||||||
"mac": None, "status": "unknown", "services": [], "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_force_creates_duplicate(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""force=True (user confirmed) bypasses the guard and creates the node."""
|
|
||||||
design = await _add_design(db_session, "Home")
|
|
||||||
db_session.add(_node(design, ip="192.168.1.100"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "dup", "type": "server", "ip": "192.168.1.100",
|
|
||||||
"status": "unknown", "services": [], "design_id": design, "force": True},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all()
|
|
||||||
assert len(nodes) == 2 # duplicate deliberately created
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_allows_same_ip_on_other_design(
|
|
||||||
client: AsyncClient, headers, db_session, pending_device
|
|
||||||
):
|
|
||||||
"""The guard is per-design: the same host on a different canvas is fine."""
|
|
||||||
other = await _add_design(db_session, "Lab")
|
|
||||||
target = await _add_design(db_session, "Home")
|
|
||||||
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "ok", "type": "server", "ip": "192.168.1.100",
|
|
||||||
"status": "unknown", "services": [], "design_id": target},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_places_already_approved_on_another_design(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""A device already approved on ANOTHER canvas (global status="approved")
|
|
||||||
must still be placeable on a new design — status is global, canvas
|
|
||||||
membership is per-design (mirrors bulk_approve)."""
|
|
||||||
other = await _add_design(db_session, "Other")
|
|
||||||
target = await _add_design(db_session, "Network Topology")
|
|
||||||
# Device is on `other` already (its global status is "approved").
|
|
||||||
db_session.add(_node(other, ieee="0x00158d0005292b83"))
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
|
|
||||||
suggested_type="zigbee_enddevice", status="approved",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{device.id}/approve",
|
|
||||||
json={"label": "sensor", "type": "zigbee_enddevice", "status": "online",
|
|
||||||
"services": [], "design_id": target},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
# A node now exists on the target design too (one per canvas).
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
assert {n.design_id for n in nodes} == {other, target}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device):
|
|
||||||
"""A user-hidden device is not approvable via this endpoint."""
|
|
||||||
pending_device.status = "hidden"
|
|
||||||
db_session.add(pending_device)
|
|
||||||
await db_session.commit()
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "x", "type": "server", "status": "unknown", "services": []},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
|
||||||
node_payload = {
|
|
||||||
"label": "Ghost",
|
|
||||||
"type": "generic",
|
|
||||||
"ip": "10.0.0.1",
|
|
||||||
"status": "unknown",
|
|
||||||
"services": [],
|
|
||||||
}
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/nonexistent-id/approve",
|
|
||||||
json=node_payload,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
|
||||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["hidden"] is True
|
|
||||||
|
|
||||||
# Should no longer appear in pending
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert pending_res.json() == []
|
|
||||||
|
|
||||||
# Should appear in hidden
|
|
||||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
||||||
assert len(hidden_res.json()) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_restore_device(client: AsyncClient, headers, pending_device):
|
|
||||||
# Hide first
|
|
||||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["restored"] is True
|
|
||||||
|
|
||||||
# Now back in pending, gone from hidden
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert len(pending_res.json()) == 1
|
|
||||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
||||||
assert hidden_res.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device):
|
|
||||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers)
|
|
||||||
assert res.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device):
|
|
||||||
# Hide
|
|
||||||
await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-restore",
|
|
||||||
headers=headers,
|
|
||||||
json={"device_ids": [pending_device.id]},
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["restored"] == 1
|
|
||||||
assert res.json()["skipped"] == 0
|
|
||||||
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert len(pending_res.json()) == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
|
||||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["ignored"] is True
|
|
||||||
|
|
||||||
# Device should be gone from both pending and hidden
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert pending_res.json() == []
|
|
||||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
||||||
assert hidden_res.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] == 2
|
|
||||||
assert len(data["node_ids"]) == 2
|
|
||||||
assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs"
|
|
||||||
assert len(data["device_ids"]) == 2
|
|
||||||
assert data["skipped"] == 0
|
|
||||||
# Approved devices stay in the inventory, now marked "approved".
|
|
||||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
inventory = pending_res.json()
|
|
||||||
assert len(inventory) == 2
|
|
||||||
assert all(d["status"] == "approved" for d in inventory)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_places_already_approved_device_on_another_design(
|
|
||||||
client: AsyncClient, headers, db_session, two_pending_devices
|
|
||||||
):
|
|
||||||
"""Regression: a device already approved (status='approved', e.g. placed on
|
|
||||||
another canvas) must still get a node on the design being approved onto.
|
|
||||||
|
|
||||||
Previously bulk-approve filtered status=='pending', so selecting an
|
|
||||||
already-approved device created no node — the user saw fewer nodes than
|
|
||||||
they selected."""
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
design_a = await _add_design(db_session, "Canvas A")
|
|
||||||
design_b = await _add_design(db_session, "Canvas B")
|
|
||||||
|
|
||||||
# Approve both onto design A.
|
|
||||||
res_a = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": ids, "design_id": design_a},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res_a.json()["approved"] == 2
|
|
||||||
|
|
||||||
# Re-approve the same (now status='approved') devices onto design B.
|
|
||||||
res_b = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": ids, "design_id": design_b},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
data_b = res_b.json()
|
|
||||||
assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas"
|
|
||||||
assert data_b["skipped"] == 0
|
|
||||||
|
|
||||||
# Two nodes now exist on each design.
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
nodes_b = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b))
|
|
||||||
).scalars().all()
|
|
||||||
assert len(nodes_b) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_skips_device_already_on_target_design(
|
|
||||||
client: AsyncClient, headers, db_session, two_pending_devices
|
|
||||||
):
|
|
||||||
"""A device already on the target canvas (same ip) is not placed twice."""
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
design = await _add_design(db_session, "Canvas")
|
|
||||||
# First device already sits on the canvas (matched by ip).
|
|
||||||
db_session.add(_node(design, ip="192.168.1.10"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": ids, "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
|
||||||
assert data["skipped"] == 1
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
|
|
||||||
).scalars().all()
|
|
||||||
# The pre-existing node plus the one newly approved — no duplicate for .10.
|
|
||||||
assert len(nodes) == 2
|
|
||||||
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
|
|
||||||
client: AsyncClient, headers, db_session, two_pending_devices
|
|
||||||
):
|
|
||||||
"""The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned
|
|
||||||
as the plain IPv4 is still recognised as already placed (issue #258)."""
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
design = await _add_design(db_session, "Canvas")
|
|
||||||
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": ids, "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] == 1 # only the second device (192.168.1.11)
|
|
||||||
assert data["skipped"] == 1
|
|
||||||
assert data["skipped_devices"][0]["value"] == "192.168.1.10"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_reports_skipped_devices(
|
|
||||||
client: AsyncClient, headers, db_session, two_pending_devices
|
|
||||||
):
|
|
||||||
"""Bulk can't prompt per-device, so it reports each duplicate it skipped
|
|
||||||
(with the existing node id) instead of silently dropping it."""
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
design = await _add_design(db_session, "Canvas")
|
|
||||||
existing = _node(design, ip="192.168.1.10")
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": ids, "design_id": design},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] == 1
|
|
||||||
skipped = data["skipped_devices"]
|
|
||||||
assert len(skipped) == 1
|
|
||||||
entry = skipped[0]
|
|
||||||
assert entry["match"] == "ip"
|
|
||||||
assert entry["value"] == "192.168.1.10"
|
|
||||||
assert entry["existing_node_id"] == existing.id
|
|
||||||
assert entry["device_id"] in ids
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_copies_mac_to_node_and_properties(
|
|
||||||
client: AsyncClient, headers, pending_device, db_session
|
|
||||||
):
|
|
||||||
"""Approving a scanned device must carry its MAC onto the node + properties."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
# Payload intentionally omits mac — it must come from the pending device.
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
|
||||||
).scalar_one()
|
|
||||||
assert node.mac == "aa:bb:cc:dd:ee:ff"
|
|
||||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
|
||||||
assert mac_props == [
|
|
||||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_copies_mac_to_node_and_properties(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""Bulk approve must also propagate the scanned MAC to node + properties."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip="192.168.1.55",
|
|
||||||
mac="11:22:33:44:55:66",
|
|
||||||
hostname="host-mac",
|
|
||||||
services=[],
|
|
||||||
suggested_type="generic",
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": [device.id]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
|
|
||||||
).scalar_one()
|
|
||||||
assert node.mac == "11:22:33:44:55:66"
|
|
||||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
|
||||||
assert mac_props == [
|
|
||||||
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session):
|
|
||||||
"""Approved devices with an IP must default to ping; otherwise scheduler skips them."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
|
|
||||||
for n in nodes:
|
|
||||||
if n.ip:
|
|
||||||
assert n.check_method == "ping", f"node {n.id} created without check_method"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session):
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (await db_session.execute(select(NodeModel))).scalars().first()
|
|
||||||
assert node is not None
|
|
||||||
assert node.check_method == "ping"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
# Approve first device individually first
|
|
||||||
await client.post(
|
|
||||||
f"/api/v1/scan/pending/{ids[0]}/approve",
|
|
||||||
json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
# Bulk approve both — first one is already approved (not pending), should be skipped
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] == 1
|
|
||||||
assert data["skipped"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["hidden"] == 2
|
|
||||||
assert data["skipped"] == 0
|
|
||||||
# Should appear in hidden list
|
|
||||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
|
||||||
assert len(hidden_res.json()) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
# Hide first device individually first
|
|
||||||
await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers)
|
|
||||||
# Bulk hide both — first is already hidden (not pending anymore)
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["hidden"] == 1
|
|
||||||
assert data["skipped"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices):
|
|
||||||
ids = [d.id for d in two_pending_devices]
|
|
||||||
res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_targets_requested_design(client, headers, db_session):
|
|
||||||
"""bulk-approve must place nodes on the design_id sent by the UI, not the
|
|
||||||
first design — otherwise approved devices land on the wrong canvas."""
|
|
||||||
first = await _add_design(db_session, "Default") # first design (fallback)
|
|
||||||
active = await _add_design(db_session, "zwave") # the design the user is on
|
|
||||||
dev = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ieee_address="zwave-H-2",
|
|
||||||
friendly_name="Living Room Plug",
|
|
||||||
suggested_type="zwave_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
vendor="Aeotec",
|
|
||||||
model="ZW096",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
db_session.add(dev)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": [dev.id], "design_id": active},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["approved"] == 1
|
|
||||||
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
|
|
||||||
).scalar_one()
|
|
||||||
assert node.design_id == active
|
|
||||||
assert node.design_id != first
|
|
||||||
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
|
|
||||||
assert node.status == "online"
|
|
||||||
assert node.check_method == "none"
|
|
||||||
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
"""MAC-property builders and Zigbee/Z-Wave property population on approve."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_zigbee_device_populates_properties(
|
|
||||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
|
||||||
):
|
|
||||||
"""Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
payload = {
|
|
||||||
"label": "bulb_1",
|
|
||||||
"type": "zigbee_enddevice",
|
|
||||||
"status": "online",
|
|
||||||
"services": [],
|
|
||||||
"check_method": "none",
|
|
||||||
}
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve",
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
|
||||||
).scalar_one()
|
|
||||||
keys = {p["key"]: p["value"] for p in node.properties}
|
|
||||||
assert keys == {
|
|
||||||
"IEEE": "0xABCDEF",
|
|
||||||
"Vendor": "IKEA",
|
|
||||||
"Model": "TRADFRI",
|
|
||||||
"LQI": "180",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bulk_approve_zigbee_populates_properties(
|
|
||||||
client: AsyncClient, headers, zigbee_pending_device, db_session
|
|
||||||
):
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/pending/bulk-approve",
|
|
||||||
json={"device_ids": [zigbee_pending_device.id]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
|
|
||||||
).scalar_one()
|
|
||||||
keys = {p["key"]: p["value"] for p in node.properties}
|
|
||||||
assert keys["IEEE"] == "0xABCDEF"
|
|
||||||
assert keys["Vendor"] == "IKEA"
|
|
||||||
assert keys["Model"] == "TRADFRI"
|
|
||||||
assert keys["LQI"] == "180"
|
|
||||||
assert node.check_method == "none"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_mac_property_returns_hidden_row():
|
|
||||||
from app.api.routes.scan import build_mac_property
|
|
||||||
|
|
||||||
assert build_mac_property("aa:bb:cc:dd:ee:ff") == [
|
|
||||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_mac_property_empty_when_no_mac():
|
|
||||||
from app.api.routes.scan import build_mac_property
|
|
||||||
|
|
||||||
assert build_mac_property(None) == []
|
|
||||||
assert build_mac_property("") == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_mac_property_appends_when_absent():
|
|
||||||
from app.api.routes.scan import merge_mac_property
|
|
||||||
|
|
||||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
|
||||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
|
||||||
assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged
|
|
||||||
# Existing prop preserved untouched.
|
|
||||||
assert existing[0] in merged
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_mac_property_idempotent_and_preserves_visibility():
|
|
||||||
from app.api.routes.scan import merge_mac_property
|
|
||||||
|
|
||||||
existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}]
|
|
||||||
merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff")
|
|
||||||
# No duplicate MAC row; user's visible=True choice kept.
|
|
||||||
macs = [p for p in merged if p["key"] == "MAC"]
|
|
||||||
assert len(macs) == 1
|
|
||||||
assert macs[0]["visible"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_merge_mac_property_noop_without_mac():
|
|
||||||
from app.api.routes.scan import merge_mac_property
|
|
||||||
|
|
||||||
existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}]
|
|
||||||
assert merge_mac_property(existing, None) == existing
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_device_does_not_duplicate_mac_property(
|
|
||||||
client: AsyncClient, headers, pending_device, db_session
|
|
||||||
):
|
|
||||||
"""If the approve payload already carries a MAC prop, don't add a second one."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Node as NodeModel
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
|
||||||
json={
|
|
||||||
"label": "My Server",
|
|
||||||
"type": "server",
|
|
||||||
"ip": "192.168.1.100",
|
|
||||||
"status": "unknown",
|
|
||||||
"services": [],
|
|
||||||
"properties": [
|
|
||||||
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
|
|
||||||
).scalar_one()
|
|
||||||
mac_props = [p for p in node.properties if p["key"] == "MAC"]
|
|
||||||
assert len(mac_props) == 1
|
|
||||||
# User's visibility choice is preserved.
|
|
||||||
assert mac_props[0]["visible"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Edge
|
|
||||||
|
|
||||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{pending.id}/approve",
|
|
||||||
json={
|
|
||||||
"label": "router_1",
|
|
||||||
"type": "zigbee_router",
|
|
||||||
"ip": None,
|
|
||||||
"status": "unknown",
|
|
||||||
"services": [],
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["approved"] is True
|
|
||||||
assert data["edges_created"] == 1
|
|
||||||
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
assert len(edges) == 1
|
|
||||||
assert edges[0].source == coord.id
|
|
||||||
assert edges[0].target == data["node_id"]
|
|
||||||
assert edges[0].source_handle == "bottom"
|
|
||||||
# Bare side name (canonical stored form); renders at the top like before.
|
|
||||||
assert edges[0].target_handle == "top"
|
|
||||||
assert edges[0].type == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_zigbee_skips_duplicate_edge(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""Re-running the resolution does not create a second edge for the same pair."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
|
||||||
|
|
||||||
coord, pending = await _seed_zigbee_pending_pair(db_session)
|
|
||||||
body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
|
||||||
await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers)
|
|
||||||
|
|
||||||
# Simulate a second pending row + link between same coord and a new device,
|
|
||||||
# but keep an existing edge in place to verify dedupe also handles
|
|
||||||
# the swapped-direction case.
|
|
||||||
new_pending = PendingDevice(
|
|
||||||
ieee_address="0xR1B",
|
|
||||||
friendly_name="r1b",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(new_pending)
|
|
||||||
db_session.add(
|
|
||||||
PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee")
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers
|
|
||||||
)
|
|
||||||
assert res.json()["edges_created"] == 1 # only the new pair
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
assert len(edges) == 2 # original + new, no duplicate
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_zigbee_skips_when_other_endpoint_still_pending(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""Both endpoints pending → no edge yet, link row preserved for later."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
|
||||||
|
|
||||||
a = PendingDevice(
|
|
||||||
ieee_address="0xA",
|
|
||||||
friendly_name="a",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
b = PendingDevice(
|
|
||||||
ieee_address="0xB",
|
|
||||||
friendly_name="b",
|
|
||||||
suggested_type="zigbee_enddevice",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add_all([a, b])
|
|
||||||
db_session.add(
|
|
||||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{a.id}/approve",
|
|
||||||
json={
|
|
||||||
"label": "a",
|
|
||||||
"type": "zigbee_router",
|
|
||||||
"ip": None,
|
|
||||||
"status": "unknown",
|
|
||||||
"services": [],
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["edges_created"] == 0
|
|
||||||
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
assert edges == []
|
|
||||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
|
||||||
assert len(links) == 1 # preserved for later resolution
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_approve_zigbee_resolves_link_after_second_approval(
|
|
||||||
client: AsyncClient, headers, db_session
|
|
||||||
):
|
|
||||||
"""First approval keeps link (other endpoint pending); second approval
|
|
||||||
creates the edge. The link row is retained afterwards so the same pair can
|
|
||||||
be re-approved onto another canvas — it's topology, wiped only on reimport."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Edge, PendingDevice, PendingDeviceLink
|
|
||||||
|
|
||||||
a = PendingDevice(
|
|
||||||
ieee_address="0xA",
|
|
||||||
friendly_name="a",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
b = PendingDevice(
|
|
||||||
ieee_address="0xB",
|
|
||||||
friendly_name="b",
|
|
||||||
suggested_type="zigbee_enddevice",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add_all([a, b])
|
|
||||||
db_session.add(
|
|
||||||
PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee")
|
|
||||||
)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []}
|
|
||||||
await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers)
|
|
||||||
res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers)
|
|
||||||
assert res.json()["edges_created"] == 1
|
|
||||||
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
assert len(edges) == 1
|
|
||||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
|
||||||
assert len(links) == 1 # retained for re-approval onto other canvases
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session):
|
|
||||||
active = await _add_design(db_session, "zwave")
|
|
||||||
dev = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ieee_address="zwave-H-9",
|
|
||||||
friendly_name="Door Sensor",
|
|
||||||
suggested_type="zwave_enddevice",
|
|
||||||
vendor="Aeotec",
|
|
||||||
model="ZW120",
|
|
||||||
status="pending",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
db_session.add(dev)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/scan/pending/{dev.id}/approve",
|
|
||||||
json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
node = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
|
|
||||||
).scalar_one()
|
|
||||||
assert node.design_id == active
|
|
||||||
assert node.status == "online"
|
|
||||||
assert node.check_method == "none"
|
|
||||||
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"""Scan API routes: trigger, pending list, canvas-count correlation, timestamps, config."""
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
from tests.scan.helpers import _add_design, _node
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.post("/api/v1/scan/trigger")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
|
||||||
with (
|
|
||||||
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
|
||||||
patch("app.api.routes.scan.settings") as mock_settings,
|
|
||||||
):
|
|
||||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
||||||
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["status"] == "running"
|
|
||||||
assert data["ranges"] == ["192.168.1.0/24"]
|
|
||||||
assert "id" in data
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_pending_empty(client: AsyncClient, headers):
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert len(data) == 1
|
|
||||||
assert data[0]["ip"] == "192.168.1.100"
|
|
||||||
assert data[0]["hostname"] == "my-server"
|
|
||||||
# No matching node → not on any canvas.
|
|
||||||
assert data[0]["canvas_count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session, pending_device):
|
|
||||||
# Node.ip holds several comma-separated addresses (IPv6 added first). The
|
|
||||||
# device scanned as the plain IPv4 must still correlate (issue #258).
|
|
||||||
d1 = await _add_design(db_session, "Home")
|
|
||||||
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
|
||||||
assert data[0]["canvas_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canvas_count_correlates_by_mac(client, headers, db_session, pending_device):
|
|
||||||
# Node's ip differs entirely (user edited it) but the MAC still matches:
|
|
||||||
# the device is on the canvas (issue #258, MAC is the stable identifier).
|
|
||||||
d1 = await _add_design(db_session, "Home")
|
|
||||||
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
|
||||||
assert data[0]["canvas_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
|
|
||||||
# Same IP placed on two different canvases → canvas_count == 2.
|
|
||||||
d1 = await _add_design(db_session, "Home")
|
|
||||||
d2 = await _add_design(db_session, "Lab")
|
|
||||||
db_session.add(_node(d1, ip="192.168.1.100"))
|
|
||||||
db_session.add(_node(d2, ip="192.168.1.100"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
data = res.json()
|
|
||||||
assert len(data) == 1
|
|
||||||
assert data[0]["canvas_count"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
|
||||||
device = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
|
|
||||||
suggested_type="zigbee_enddevice", services=[], status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(device)
|
|
||||||
d1 = await _add_design(db_session, "Zigbee")
|
|
||||||
db_session.add(_node(d1, ieee="0x00124b001"))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
by_id = {d["id"]: d for d in res.json()}
|
|
||||||
assert by_id[device.id]["canvas_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
|
|
||||||
# No matching canvas node → node_* timestamps are all null; the device still
|
|
||||||
# carries its own discovered_at for the "Discovered" fallback on the tile.
|
|
||||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
||||||
assert data["discovered_at"] is not None
|
|
||||||
assert data["node_created_at"] is None
|
|
||||||
assert data["node_last_scan"] is None
|
|
||||||
assert data["node_last_modified"] is None
|
|
||||||
assert data["node_last_seen"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
|
|
||||||
d1 = await _add_design(db_session, "Home")
|
|
||||||
node = _node(d1, ip="192.168.1.100")
|
|
||||||
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
|
||||||
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
|
||||||
db_session.add(node)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
||||||
assert data["node_created_at"] is not None # defaulted on insert
|
|
||||||
assert data["node_last_modified"] is not None # updated_at defaulted on insert
|
|
||||||
assert data["node_last_scan"].startswith("2026-06-01")
|
|
||||||
assert data["node_last_seen"].startswith("2026-06-25")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
|
|
||||||
# Two canvas nodes share the device IP: created_at takes the OLDEST,
|
|
||||||
# last_scan takes the NEWEST.
|
|
||||||
d1 = await _add_design(db_session, "Home")
|
|
||||||
d2 = await _add_design(db_session, "Lab")
|
|
||||||
older = _node(d1, ip="192.168.1.100")
|
|
||||||
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
||||||
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
|
|
||||||
newer = _node(d2, ip="192.168.1.100")
|
|
||||||
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
|
||||||
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
|
||||||
db_session.add_all([older, newer])
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
||||||
assert data["node_created_at"].startswith("2026-01-01") # oldest
|
|
||||||
assert data["node_last_scan"].startswith("2026-06-01") # newest
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_deep_scan_falls_back_to_settings():
|
|
||||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
|
||||||
|
|
||||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
||||||
mock_settings.scanner_http_ranges = ["7000-7100"]
|
|
||||||
mock_settings.scanner_http_probe_enabled = True
|
|
||||||
mock_settings.scanner_http_verify_tls = False
|
|
||||||
# Empty payload → all values come from settings defaults
|
|
||||||
ds = _resolve_deep_scan(TriggerScanRequest())
|
|
||||||
assert ds.http_ranges == ["7000-7100"]
|
|
||||||
assert ds.http_probe_enabled is True
|
|
||||||
assert ds.verify_tls is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_deep_scan_override_wins():
|
|
||||||
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
|
||||||
|
|
||||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
||||||
mock_settings.scanner_http_ranges = []
|
|
||||||
mock_settings.scanner_http_probe_enabled = False
|
|
||||||
mock_settings.scanner_http_verify_tls = False
|
|
||||||
ds = _resolve_deep_scan(
|
|
||||||
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
|
|
||||||
)
|
|
||||||
assert ds.http_ranges == ["9000"]
|
|
||||||
assert ds.http_probe_enabled is True
|
|
||||||
assert ds.verify_tls is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_bg(run_id, ranges, deep_scan):
|
|
||||||
captured["deep_scan"] = deep_scan
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.api.routes.scan._background_scan", new=fake_bg),
|
|
||||||
patch("app.api.routes.scan.settings") as mock_settings,
|
|
||||||
):
|
|
||||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
||||||
mock_settings.scanner_http_ranges = []
|
|
||||||
mock_settings.scanner_http_probe_enabled = False
|
|
||||||
mock_settings.scanner_http_verify_tls = False
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/trigger",
|
|
||||||
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert captured["deep_scan"].http_probe_enabled is True
|
|
||||||
assert captured["deep_scan"].http_ranges == ["8000-8100"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
|
|
||||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
||||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/trigger",
|
|
||||||
json={"http_ranges": ["70000-80000"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
|
|
||||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
||||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
||||||
mock_settings.scanner_http_ranges = ["8000-8100"]
|
|
||||||
mock_settings.scanner_http_probe_enabled = True
|
|
||||||
mock_settings.scanner_http_verify_tls = False
|
|
||||||
res = await client.get("/api/v1/scan/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["http_ranges"] == ["8000-8100"]
|
|
||||||
assert data["http_probe_enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
|
|
||||||
saved = {}
|
|
||||||
|
|
||||||
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
||||||
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
||||||
mock_settings.scanner_http_ranges = []
|
|
||||||
mock_settings.scanner_http_probe_enabled = False
|
|
||||||
mock_settings.scanner_http_verify_tls = False
|
|
||||||
mock_settings.save_overrides = lambda: saved.update(
|
|
||||||
http_ranges=mock_settings.scanner_http_ranges,
|
|
||||||
probe=mock_settings.scanner_http_probe_enabled,
|
|
||||||
)
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/scan/config",
|
|
||||||
json={
|
|
||||||
"ranges": ["192.168.1.0/24"],
|
|
||||||
"http_ranges": ["9000-9100"],
|
|
||||||
"http_probe_enabled": True,
|
|
||||||
"verify_tls": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
"""run_scan service persistence, _background_scan lifecycle, stop/cancel."""
|
|
||||||
import uuid
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
|
||||||
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
|
||||||
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
|
||||||
session rollback path must execute without a follow-on exception."""
|
|
||||||
from app.api.routes.scan import _background_scan
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
|
||||||
session.add(run)
|
|
||||||
await session.commit()
|
|
||||||
run_id = run.id
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
||||||
patch(
|
|
||||||
"app.api.routes.scan.run_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=RuntimeError("boom"),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
refreshed = await session.get(ScanRun, run_id)
|
|
||||||
assert refreshed is not None
|
|
||||||
assert refreshed.status == "failed"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
|
||||||
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
|
||||||
must NOT overwrite that terminal status with 'failed'."""
|
|
||||||
from app.api.routes.scan import _background_scan
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
|
||||||
session.add(run)
|
|
||||||
await session.commit()
|
|
||||||
run_id = run.id
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
||||||
patch(
|
|
||||||
"app.api.routes.scan.run_scan",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=RuntimeError("boom"),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await _background_scan(run_id, ["10.0.0.0/24"])
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
refreshed = await session.get(ScanRun, run_id)
|
|
||||||
assert refreshed is not None
|
|
||||||
assert refreshed.status == "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
|
||||||
from app.api.routes.scan import _background_scan
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
|
||||||
session.add(run)
|
|
||||||
await session.commit()
|
|
||||||
run_id = run.id
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
||||||
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
|
||||||
):
|
|
||||||
from app.services.scanner import DeepScanOptions
|
|
||||||
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
|
|
||||||
mock_run_scan.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_list_runs_empty(client: AsyncClient, headers):
|
|
||||||
res = await client.get("/api/v1/scan/runs", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- run_scan: re-scan updates existing pending devices ---
|
|
||||||
|
|
||||||
MOCK_HOST = {
|
|
||||||
"ip": "192.168.1.50",
|
|
||||||
"mac": "aa:bb:cc:dd:ee:ff",
|
|
||||||
"hostname": "myhost.lan",
|
|
||||||
"os": "Linux",
|
|
||||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
||||||
)
|
|
||||||
device = result.scalar_one_or_none()
|
|
||||||
assert device is not None
|
|
||||||
assert device.hostname == "myhost.lan"
|
|
||||||
assert any(s["port"] == 8096 for s in device.services)
|
|
||||||
assert device.suggested_type == "server"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
|
||||||
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
|
|
||||||
in the inventory and are surfaced with an "In N canvas" badge."""
|
|
||||||
node = Node(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
label="Existing Server",
|
|
||||||
type="server",
|
|
||||||
ip="192.168.1.50",
|
|
||||||
status="online",
|
|
||||||
services=[],
|
|
||||||
pos_x=0.0,
|
|
||||||
pos_y=0.0,
|
|
||||||
)
|
|
||||||
stale = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip="192.168.1.50",
|
|
||||||
mac=None,
|
|
||||||
hostname=None,
|
|
||||||
os=None,
|
|
||||||
services=[],
|
|
||||||
suggested_type="generic",
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(node)
|
|
||||||
db_session.add(stale)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
||||||
)
|
|
||||||
assert result.scalar_one_or_none() is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
|
||||||
"""A scanned IP that already exists as a canvas Node still produces a pending
|
|
||||||
device (no longer suppressed)."""
|
|
||||||
node = Node(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
label="Existing Server",
|
|
||||||
type="server",
|
|
||||||
ip="192.168.1.50",
|
|
||||||
status="online",
|
|
||||||
services=[],
|
|
||||||
pos_x=0.0,
|
|
||||||
pos_y=0.0,
|
|
||||||
)
|
|
||||||
db_session.add(node)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
||||||
)
|
|
||||||
device = result.scalar_one_or_none()
|
|
||||||
assert device is not None
|
|
||||||
assert device.status == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
|
|
||||||
"""Re-scanning an already-approved device updates its row in place instead of
|
|
||||||
spawning a fresh pending duplicate, and keeps it approved."""
|
|
||||||
approved = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
|
||||||
os=None, services=[], suggested_type="server", status="approved",
|
|
||||||
)
|
|
||||||
db_session.add(approved)
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
rows = (await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
||||||
)).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].status == "approved"
|
|
||||||
assert rows[0].hostname == "myhost.lan" # refreshed from the scan
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
|
|
||||||
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
|
|
||||||
row at scan start, even if the device is not re-discovered."""
|
|
||||||
for status in ("approved", "pending", "pending"):
|
|
||||||
db_session.add(PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
|
|
||||||
os=None, services=[], suggested_type="server", status=status,
|
|
||||||
))
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
rows = (await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
|
|
||||||
)).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].status == "approved" # approved row is the one kept
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
|
||||||
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
|
||||||
hidden = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip="192.168.1.50",
|
|
||||||
mac=None,
|
|
||||||
hostname=None,
|
|
||||||
os=None,
|
|
||||||
services=[],
|
|
||||||
suggested_type="generic",
|
|
||||||
status="hidden",
|
|
||||||
)
|
|
||||||
db_session.add(hidden)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(PendingDevice).where(
|
|
||||||
PendingDevice.ip == "192.168.1.50",
|
|
||||||
PendingDevice.status == "pending",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
assert result.scalar_one_or_none() is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_scan_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.post("/api/v1/scan/fake-id/stop")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
|
||||||
import uuid as _uuid
|
|
||||||
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
|
||||||
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
|
||||||
assert res.status_code == 409
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
|
||||||
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"stopping": True}
|
|
||||||
# run_id added to cancel set
|
|
||||||
assert run.id in _cancelled_runs
|
|
||||||
# status flipped eagerly so the UI reacts without waiting for a checkpoint
|
|
||||||
await db_session.refresh(run)
|
|
||||||
assert run.status == "cancelled"
|
|
||||||
assert run.finished_at is not None
|
|
||||||
# cleanup for other tests
|
|
||||||
_cancelled_runs.discard(run.id)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
|
||||||
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
request_cancel(run_id)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
# nmap should not have been called — cancelled before first range
|
|
||||||
mock_nmap.assert_not_called()
|
|
||||||
|
|
||||||
await db_session.refresh(run)
|
|
||||||
assert run.status == "cancelled"
|
|
||||||
assert run.finished_at is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
|
||||||
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
# Signal cancellation after the first CIDR scan completes
|
|
||||||
if call_count == 1:
|
|
||||||
request_cancel(run_id)
|
|
||||||
return []
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
assert call_count == 1 # second CIDR was skipped
|
|
||||||
await db_session.refresh(run)
|
|
||||||
assert run.status == "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
|
||||||
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
|
||||||
# Pre-existing pending device with no services
|
|
||||||
existing = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
ip="192.168.1.50",
|
|
||||||
mac=None,
|
|
||||||
hostname=None,
|
|
||||||
os=None,
|
|
||||||
services=[],
|
|
||||||
suggested_type="generic",
|
|
||||||
status="pending",
|
|
||||||
)
|
|
||||||
db_session.add(existing)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
run_id = str(uuid.uuid4())
|
|
||||||
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
db_session.add(run)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
||||||
):
|
|
||||||
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
||||||
|
|
||||||
# Should still be only one device
|
|
||||||
result = await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
||||||
)
|
|
||||||
devices = list(result.scalars().all())
|
|
||||||
assert len(devices) == 1
|
|
||||||
device = devices[0]
|
|
||||||
# Services and hostname should be updated
|
|
||||||
assert device.hostname == "myhost.lan"
|
|
||||||
assert any(s["port"] == 8096 for s in device.services)
|
|
||||||
@@ -56,83 +56,3 @@ async def test_service_key_disabled_when_not_configured(client: AsyncClient):
|
|||||||
settings.mcp_service_key = ""
|
settings.mcp_service_key = ""
|
||||||
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
res = await client.get("/api/v1/nodes", headers={"X-MCP-Service-Key": "any-key"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_login_with_malformed_hash_returns_401_not_500(client: AsyncClient):
|
|
||||||
"""Malformed hash (e.g. $ stripped by shell) must not crash with 500."""
|
|
||||||
from app.core.config import settings
|
|
||||||
original = settings.auth_password_hash
|
|
||||||
settings.auth_password_hash = "2b12RtMbyw17l4N5UGzeXMNAWu" # $ signs stripped
|
|
||||||
try:
|
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
finally:
|
|
||||||
settings.auth_password_hash = original
|
|
||||||
|
|
||||||
|
|
||||||
# --- JWT-level cases ---
|
|
||||||
|
|
||||||
async def test_expired_token_rejected(client: AsyncClient):
|
|
||||||
"""A JWT whose `exp` is in the past must be refused."""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from jose import jwt
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
payload = {
|
|
||||||
"sub": "admin",
|
|
||||||
"exp": datetime.now(timezone.utc) - timedelta(minutes=1),
|
|
||||||
}
|
|
||||||
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
|
||||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_malformed_token_rejected(client: AsyncClient):
|
|
||||||
res = await client.get("/api/v1/nodes", headers={"Authorization": "Bearer not-a-jwt"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
|
|
||||||
"""A token signed with a different key must not be accepted."""
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from jose import jwt
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
payload = {
|
|
||||||
"sub": "admin",
|
|
||||||
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
|
|
||||||
}
|
|
||||||
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
|
|
||||||
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_missing_authorization_header_rejected(client: AsyncClient):
|
|
||||||
res = await client.get("/api/v1/nodes")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_empty_password_does_not_pass_when_hash_empty(client: AsyncClient):
|
|
||||||
"""No credentials configured server-side must not authenticate an empty password."""
|
|
||||||
from app.core.config import settings
|
|
||||||
original_hash = settings.auth_password_hash
|
|
||||||
settings.auth_password_hash = ""
|
|
||||||
try:
|
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": ""})
|
|
||||||
assert res.status_code == 401
|
|
||||||
finally:
|
|
||||||
settings.auth_password_hash = original_hash
|
|
||||||
|
|
||||||
|
|
||||||
# --- Password helper ---
|
|
||||||
|
|
||||||
def test_verify_password_handles_empty_inputs():
|
|
||||||
"""verify_password must be safe against empty plain / empty hash without raising."""
|
|
||||||
from app.core.security import hash_password, verify_password
|
|
||||||
h = hash_password("hunter2")
|
|
||||||
assert verify_password("hunter2", h) is True
|
|
||||||
assert verify_password("", h) is False
|
|
||||||
assert verify_password("hunter2", "") is False
|
|
||||||
assert verify_password("", "") is False
|
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
return {"Authorization": f"Bearer {res.json()['access_token']}"}
|
||||||
|
|
||||||
|
|
||||||
def node_payload(**kwargs):
|
def node_payload(**kwargs):
|
||||||
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
||||||
|
|
||||||
@@ -22,23 +29,6 @@ async def test_load_canvas_empty(client: AsyncClient, headers: dict):
|
|||||||
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
assert data["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_uninitialized_reports_initialized_false(client: AsyncClient, headers: dict):
|
|
||||||
# A never-saved canvas has no CanvasState row → initialized False. The frontend
|
|
||||||
# uses this to show the demo canvas only to genuinely new users.
|
|
||||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert data["initialized"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_initialized_true_after_save(client: AsyncClient, headers: dict):
|
|
||||||
# Saving an EMPTY canvas still creates a CanvasState row, so a subsequently
|
|
||||||
# loaded empty canvas is reported initialized — the user cleared it on purpose
|
|
||||||
# and must not get the demo re-seeded.
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
data = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert data["nodes"] == []
|
|
||||||
assert data["initialized"] is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_requires_auth(client: AsyncClient):
|
async def test_load_canvas_requires_auth(client: AsyncClient):
|
||||||
res = await client.get("/api/v1/canvas")
|
res = await client.get("/api/v1/canvas")
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
@@ -61,88 +51,6 @@ async def test_save_canvas_creates_nodes_and_edges(client: AsyncClient, headers:
|
|||||||
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
assert canvas["viewport"] == {"x": 1, "y": 2, "zoom": 1.5}
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(label="Router", type="router")
|
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
||||||
assert edge["marker_start"] == "diamond"
|
|
||||||
assert edge["marker_end"] == "arrow"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_round_trips_line_style_and_width(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(label="Router", type="router")
|
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], type="wifi", line_style="dotted", width_mult=3)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
||||||
assert edge["line_style"] == "dotted"
|
|
||||||
assert edge["width_mult"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(label="Router", type="router")
|
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
||||||
assert edge["marker_start"] == "arrow"
|
|
||||||
assert edge["marker_end"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(label="Router", type="router")
|
|
||||||
n2 = node_payload(label="Switch", type="switch")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
|
|
||||||
assert edge["marker_start"] == "none"
|
|
||||||
assert edge["marker_end"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
|
|
||||||
# Regression (#243): top/left/right_handles must persist across save+reload,
|
|
||||||
# not just bottom_handles.
|
|
||||||
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
|
|
||||||
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
||||||
assert node["top_handles"] == 2
|
|
||||||
assert node["bottom_handles"] == 3
|
|
||||||
assert node["left_handles"] == 1
|
|
||||||
assert node["right_handles"] == 4
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
|
|
||||||
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
|
|
||||||
n1 = node_payload(label="Srv", type="server")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
|
|
||||||
|
|
||||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
||||||
assert node["top_handles"] == 1
|
|
||||||
assert node["bottom_handles"] == 1
|
|
||||||
assert node["left_handles"] == 0
|
|
||||||
assert node["right_handles"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(label="Router", type="router")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
|
|
||||||
# created_at / updated_at always set; last_seen / last_scan null until observed.
|
|
||||||
assert node["created_at"] is not None
|
|
||||||
assert node["updated_at"] is not None
|
|
||||||
assert "last_seen" in node
|
|
||||||
assert node["last_scan"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
async def test_save_canvas_updates_existing_node(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(label="Old Label")
|
n1 = node_payload(label="Old Label")
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
@@ -196,25 +104,6 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
|
|||||||
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
|
|
||||||
"""label_position and text_size are stored in custom_colors and returned unchanged."""
|
|
||||||
n1 = node_payload(custom_colors={
|
|
||||||
"border": "#00d4ff",
|
|
||||||
"border_style": "solid",
|
|
||||||
"border_width": 3,
|
|
||||||
"label_position": "outside",
|
|
||||||
"text_size": 16,
|
|
||||||
"text_color": "#e6edf3",
|
|
||||||
})
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
cc = canvas["nodes"][0]["custom_colors"]
|
|
||||||
assert cc["label_position"] == "outside"
|
|
||||||
assert cc["text_size"] == 16
|
|
||||||
assert cc["border_width"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload()
|
n1 = node_payload()
|
||||||
n2 = node_payload()
|
n2 = node_payload()
|
||||||
@@ -291,24 +180,6 @@ async def test_save_canvas_show_hardware_defaults_false(client: AsyncClient, hea
|
|||||||
assert canvas["nodes"][0]["show_hardware"] is False
|
assert canvas["nodes"][0]["show_hardware"] is False
|
||||||
|
|
||||||
|
|
||||||
# Regression (#184): show_port_numbers was dropped by the save schema, so the
|
|
||||||
# toggle reset on every reload.
|
|
||||||
async def test_save_canvas_persists_show_port_numbers(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(show_port_numbers=True)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["show_port_numbers"] is True
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_show_port_numbers_defaults_false(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["show_port_numbers"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
async def test_save_canvas_hardware_fields_cleared_on_update(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
n1 = node_payload(cpu_count=8, ram_gb=32.0)
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
@@ -366,343 +237,3 @@ async def test_save_canvas_dimensions_cleared_when_null(client: AsyncClient, hea
|
|||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
assert canvas["nodes"][0]["width"] is None
|
assert canvas["nodes"][0]["width"] is None
|
||||||
assert canvas["nodes"][0]["height"] is None
|
assert canvas["nodes"][0]["height"] is None
|
||||||
|
|
||||||
|
|
||||||
# ── properties ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_properties_default_empty(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["properties"] == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_properties(client: AsyncClient, headers: dict):
|
|
||||||
props = [
|
|
||||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True},
|
|
||||||
{"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False},
|
|
||||||
]
|
|
||||||
n1 = node_payload(properties=props)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
returned = canvas["nodes"][0]["properties"]
|
|
||||||
assert len(returned) == 2
|
|
||||||
assert returned[0] == {"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": True}
|
|
||||||
assert returned[1] == {"key": "CPU", "value": "Intel i9", "icon": "Cpu", "visible": False}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_properties_updated_on_second_save(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(properties=[{"key": "RAM", "value": "16 GB", "icon": None, "visible": True}])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
n1_updated = {**n1, "properties": [
|
|
||||||
{"key": "RAM", "value": "64 GB", "icon": "MemoryStick", "visible": True},
|
|
||||||
{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True},
|
|
||||||
]}
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_updated], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
props = canvas["nodes"][0]["properties"]
|
|
||||||
assert len(props) == 2
|
|
||||||
assert props[0]["value"] == "64 GB"
|
|
||||||
assert props[1]["key"] == "Disk"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_properties_with_null_icon(client: AsyncClient, headers: dict):
|
|
||||||
props = [{"key": "Note", "value": "custom rack", "icon": None, "visible": True}]
|
|
||||||
n1 = node_payload(properties=props)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["properties"][0]["icon"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_properties_cleared_to_empty(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(properties=[{"key": "RAM", "value": "32 GB", "icon": None, "visible": True}])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
n1_cleared = {**n1, "properties": []}
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1_cleared], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["properties"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── edge waypoints & handles ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_edge_waypoints_default_null(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["edges"][0]["waypoints"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_waypoints_on_edge(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
waypoints = [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=waypoints)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
returned = canvas["edges"][0]["waypoints"]
|
|
||||||
assert returned == [{"x": 100.0, "y": 200.0}, {"x": 300.0, "y": 150.0}]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_waypoints_updated_on_second_save(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], waypoints=[{"x": 10.0, "y": 20.0}])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
e1_updated = {**e1, "waypoints": [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]}
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["edges"][0]["waypoints"] == [{"x": 50.0, "y": 60.0}, {"x": 70.0, "y": 80.0}]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_edge_handles(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(bottom_handles=3)
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], source_handle="bottom-1", target_handle="top")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
edge = canvas["edges"][0]
|
|
||||||
assert edge["source_handle"] == "bottom-1"
|
|
||||||
assert edge["target_handle"] == "top"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_animated_edge(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], animated="snake")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["edges"][0]["animated"] == "snake"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_animated_basic(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], animated="basic")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["edges"][0]["animated"] == "basic"
|
|
||||||
|
|
||||||
|
|
||||||
# ── node fields ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_all_node_fields(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(
|
|
||||||
type="server",
|
|
||||||
label="Main Server",
|
|
||||||
hostname="server.local",
|
|
||||||
ip="192.168.1.10",
|
|
||||||
mac="aa:bb:cc:dd:ee:ff",
|
|
||||||
os="Ubuntu 22.04",
|
|
||||||
status="online",
|
|
||||||
check_method="http",
|
|
||||||
check_target="http://192.168.1.10",
|
|
||||||
services=[{"name": "nginx", "port": 80}],
|
|
||||||
notes="Primary web server",
|
|
||||||
pos_x=150.0,
|
|
||||||
pos_y=250.0,
|
|
||||||
bottom_handles=2,
|
|
||||||
)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
node = canvas["nodes"][0]
|
|
||||||
assert node["hostname"] == "server.local"
|
|
||||||
assert node["ip"] == "192.168.1.10"
|
|
||||||
assert node["mac"] == "aa:bb:cc:dd:ee:ff"
|
|
||||||
assert node["os"] == "Ubuntu 22.04"
|
|
||||||
assert node["status"] == "online"
|
|
||||||
assert node["check_method"] == "http"
|
|
||||||
assert node["check_target"] == "http://192.168.1.10"
|
|
||||||
assert node["services"] == [{"name": "nginx", "port": 80}]
|
|
||||||
assert node["notes"] == "Primary web server"
|
|
||||||
assert node["pos_x"] == 150.0
|
|
||||||
assert node["pos_y"] == 250.0
|
|
||||||
assert node["bottom_handles"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_bottom_handles(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(bottom_handles=4)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["bottom_handles"] == 4
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_bottom_handles_defaults_one(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["bottom_handles"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_services_and_notes(client: AsyncClient, headers: dict):
|
|
||||||
services = [{"name": "ssh", "port": 22}, {"name": "http", "port": 80}]
|
|
||||||
n1 = node_payload(services=services, notes="My NAS device")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
node = canvas["nodes"][0]
|
|
||||||
assert node["services"] == services
|
|
||||||
assert node["notes"] == "My NAS device"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_service_paths(client: AsyncClient, headers: dict):
|
|
||||||
services = [{"service_name": "Grafana", "protocol": "tcp", "port": 3000, "path": "/login"}]
|
|
||||||
n1 = node_payload(ip="192.168.1.50:8080", services=services)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"][0]["services"] == services
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_check_fields(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload(check_method="ping", check_target="192.168.1.1")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
node = canvas["nodes"][0]
|
|
||||||
assert node["check_method"] == "ping"
|
|
||||||
assert node["check_target"] == "192.168.1.1"
|
|
||||||
|
|
||||||
|
|
||||||
# ── parent/child nodes ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_parent_child_nodes(client: AsyncClient, headers: dict):
|
|
||||||
parent = node_payload(type="proxmox", label="PVE Host")
|
|
||||||
child = node_payload(type="vm", label="VM-100", parent_id=parent["id"])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
|
||||||
assert node_map[child["id"]]["parent_id"] == parent["id"]
|
|
||||||
assert node_map[parent["id"]]["parent_id"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_child_removed_with_parent(client: AsyncClient, headers: dict):
|
|
||||||
parent = node_payload(type="proxmox", label="PVE Host")
|
|
||||||
child = node_payload(type="lxc", label="LXC-101", parent_id=parent["id"])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [parent, child], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
# Remove both parent and child
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["nodes"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# ── groupRect / group node ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_group_node(client: AsyncClient, headers: dict):
|
|
||||||
group = node_payload(type="group", label="Network Zone", width=400.0, height=300.0)
|
|
||||||
member = node_payload(type="server", label="Member", parent_id=group["id"])
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [group, member], "edges": [], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
node_map = {n["id"]: n for n in canvas["nodes"]}
|
|
||||||
assert node_map[group["id"]]["type"] == "group"
|
|
||||||
assert node_map[group["id"]]["width"] == 400.0
|
|
||||||
assert node_map[group["id"]]["height"] == 300.0
|
|
||||||
assert node_map[member["id"]]["parent_id"] == group["id"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── viewport ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_load_canvas_returns_default_viewport_when_no_state(client: AsyncClient, headers: dict):
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["viewport"] == {"x": 0, "y": 0, "zoom": 1}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_updates_existing_canvas_state(client: AsyncClient, headers: dict):
|
|
||||||
"""Second save updates the existing CanvasState row (exercises the state.viewport branch)."""
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 1, "y": 2, "zoom": 1}}, headers=headers)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {"x": 99, "y": 88, "zoom": 0.75}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["viewport"] == {"x": 99, "y": 88, "zoom": 0.75}
|
|
||||||
|
|
||||||
|
|
||||||
# ── edge types ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_edge_type_vlan(client: AsyncClient, headers: dict):
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], type="vlan", vlan_id=10, label="VLAN 10")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
edge = canvas["edges"][0]
|
|
||||||
assert edge["type"] == "vlan"
|
|
||||||
assert edge["vlan_id"] == 10
|
|
||||||
assert edge["label"] == "VLAN 10"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_edge_update_existing(client: AsyncClient, headers: dict):
|
|
||||||
"""Second save updates an existing edge (exercises the db_edge branch)."""
|
|
||||||
n1 = node_payload()
|
|
||||||
n2 = node_payload()
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], label="original")
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
e1_updated = {**e1, "label": "updated", "custom_color": "#ff0000"}
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1_updated], "viewport": {}}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
edge = canvas["edges"][0]
|
|
||||||
assert edge["label"] == "updated"
|
|
||||||
assert edge["custom_color"] == "#ff0000"
|
|
||||||
|
|
||||||
|
|
||||||
# ── custom_style ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_save_and_load_custom_style(client: AsyncClient, headers: dict):
|
|
||||||
custom_style = {
|
|
||||||
"nodes": {
|
|
||||||
"server": {"borderColor": "#ff0000", "borderOpacity": 0.8, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#ff0000", "iconOpacity": 1, "width": 200, "height": 80},
|
|
||||||
},
|
|
||||||
"edges": {
|
|
||||||
"ethernet": {"color": "#00ff00", "opacity": 1, "pathStyle": "bezier", "animated": "none"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
payload = {"nodes": [], "edges": [], "viewport": {"theme_id": "custom"}, "custom_style": custom_style}
|
|
||||||
res = await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert canvas["custom_style"] is not None
|
|
||||||
assert canvas["custom_style"]["nodes"]["server"]["borderColor"] == "#ff0000"
|
|
||||||
assert canvas["custom_style"]["edges"]["ethernet"]["color"] == "#00ff00"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_load_canvas_custom_style_null_by_default(client: AsyncClient, headers: dict):
|
|
||||||
res = await client.get("/api/v1/canvas", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["custom_style"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_custom_style_overwrite(client: AsyncClient, headers: dict):
|
|
||||||
style_v1 = {"nodes": {"server": {"borderColor": "#aabbcc", "borderOpacity": 1, "bgColor": "#000000", "bgOpacity": 1, "iconColor": "#aabbcc", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
|
||||||
style_v2 = {"nodes": {"proxmox": {"borderColor": "#ff6e00", "borderOpacity": 1, "bgColor": "#111111", "bgOpacity": 1, "iconColor": "#ff6e00", "iconOpacity": 1, "width": 0, "height": 0}}, "edges": {}}
|
|
||||||
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v1}, headers=headers)
|
|
||||||
await client.post("/api/v1/canvas/save", json={"nodes": [], "edges": [], "viewport": {}, "custom_style": style_v2}, headers=headers)
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
|
||||||
assert "proxmox" in canvas["custom_style"]["nodes"]
|
|
||||||
assert "server" not in canvas["custom_style"]["nodes"]
|
|
||||||
|
|||||||
@@ -1,270 +0,0 @@
|
|||||||
import uuid
|
|
||||||
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
|
|
||||||
def node_payload(**kwargs):
|
|
||||||
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
|
|
||||||
|
|
||||||
|
|
||||||
def edge_payload(src, tgt, **kwargs):
|
|
||||||
return {"id": str(uuid.uuid4()), "source": src, "target": tgt, "type": "ethernet", **kwargs}
|
|
||||||
|
|
||||||
|
|
||||||
async def _create(client: AsyncClient, headers: dict, **body) -> dict:
|
|
||||||
res = await client.post("/api/v1/designs", json={"name": "D", **body}, headers=headers)
|
|
||||||
assert res.status_code == 201, res.text
|
|
||||||
return res.json()
|
|
||||||
|
|
||||||
|
|
||||||
# ── auth ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_list_designs_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.get("/api/v1/designs")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_design_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.post("/api/v1/designs", json={"name": "X"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
# ── list / create ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_list_designs_empty(client: AsyncClient, headers: dict):
|
|
||||||
res = await client.get("/api/v1/designs", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_design_defaults(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Workshop")
|
|
||||||
assert design["name"] == "Workshop"
|
|
||||||
assert design["design_type"] == "network"
|
|
||||||
assert design["icon"] == "dashboard"
|
|
||||||
assert "id" in design and design["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_design_explicit_type(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Net", design_type="network")
|
|
||||||
assert design["design_type"] == "network"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_design_with_custom_icon(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Power", icon="zap")
|
|
||||||
assert design["icon"] == "zap"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_design_changes_icon(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="D", icon="dashboard")
|
|
||||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"icon": "server"}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["icon"] == "server"
|
|
||||||
# Name left untouched when only icon is sent.
|
|
||||||
assert res.json()["name"] == "D"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_design_name_and_icon_together(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Old", icon="dashboard")
|
|
||||||
res = await client.put(
|
|
||||||
f"/api/v1/designs/{design['id']}", json={"name": "New", "icon": "network"}, headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body["name"] == "New"
|
|
||||||
assert body["icon"] == "network"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_design_creates_empty_canvas_state(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Has Canvas")
|
|
||||||
# Loading the new design returns an (empty) canvas without falling back to another design.
|
|
||||||
res = await client.get("/api/v1/canvas", params={"design_id": design["id"]}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body["nodes"] == []
|
|
||||||
assert body["edges"] == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_returns_created_designs_ordered(client: AsyncClient, headers: dict):
|
|
||||||
a = await _create(client, headers, name="First")
|
|
||||||
b = await _create(client, headers, name="Second")
|
|
||||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
|
||||||
ids = [d["id"] for d in listed]
|
|
||||||
assert ids == [a["id"], b["id"]]
|
|
||||||
|
|
||||||
|
|
||||||
# ── counts in list ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Counted")
|
|
||||||
server = node_payload(label="S", type="server")
|
|
||||||
group = node_payload(label="G", type="groupRect")
|
|
||||||
text = node_payload(label="T", type="text")
|
|
||||||
save = await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert save.status_code == 200
|
|
||||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
|
||||||
d = next(x for x in listed if x["id"] == design["id"])
|
|
||||||
assert d["node_count"] == 1
|
|
||||||
assert d["group_count"] == 1
|
|
||||||
assert d["text_count"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Empty")
|
|
||||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
|
||||||
d = next(x for x in listed if x["id"] == design["id"])
|
|
||||||
assert d["node_count"] == 0
|
|
||||||
assert d["group_count"] == 0
|
|
||||||
assert d["text_count"] == 0
|
|
||||||
|
|
||||||
|
|
||||||
# ── copy ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_copy_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict):
|
|
||||||
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict):
|
|
||||||
source = await _create(client, headers, name="Source", icon="server")
|
|
||||||
n1 = node_payload(label="A")
|
|
||||||
n2 = node_payload(label="B")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"], label="link")
|
|
||||||
save = await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert save.status_code == 200
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201, res.text
|
|
||||||
copy = res.json()
|
|
||||||
assert copy["name"] == "Copy"
|
|
||||||
assert copy["icon"] == "network"
|
|
||||||
assert copy["design_type"] == "network"
|
|
||||||
assert copy["id"] != source["id"]
|
|
||||||
|
|
||||||
# Copied canvas has the same shape but fresh node ids, and edge re-pointed.
|
|
||||||
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
|
|
||||||
assert {n["label"] for n in canvas["nodes"]} == {"A", "B"}
|
|
||||||
copied_ids = {n["id"] for n in canvas["nodes"]}
|
|
||||||
assert copied_ids.isdisjoint({n1["id"], n2["id"]})
|
|
||||||
assert len(canvas["edges"]) == 1
|
|
||||||
edge = canvas["edges"][0]
|
|
||||||
assert edge["source"] in copied_ids
|
|
||||||
assert edge["target"] in copied_ids
|
|
||||||
assert edge["label"] == "link"
|
|
||||||
assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict):
|
|
||||||
source = await _create(client, headers, name="Nested")
|
|
||||||
parent = node_payload(label="P", type="proxmox", container_mode=True)
|
|
||||||
child = node_payload(label="C", type="vm", parent_id=parent["id"])
|
|
||||||
save = await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert save.status_code == 200
|
|
||||||
|
|
||||||
res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
copy = res.json()
|
|
||||||
|
|
||||||
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
|
|
||||||
by_label = {n["label"]: n for n in canvas["nodes"]}
|
|
||||||
# Child's parent_id points at the COPIED parent, not the original.
|
|
||||||
assert by_label["C"]["parent_id"] == by_label["P"]["id"]
|
|
||||||
assert by_label["C"]["parent_id"] != parent["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict):
|
|
||||||
source = await _create(client, headers, name="Source")
|
|
||||||
n1 = node_payload(label="A")
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
|
|
||||||
src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json()
|
|
||||||
assert len(src_canvas["nodes"]) == 1
|
|
||||||
assert src_canvas["nodes"][0]["id"] == n1["id"]
|
|
||||||
|
|
||||||
|
|
||||||
# ── update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Old Name")
|
|
||||||
res = await client.put(f"/api/v1/designs/{design['id']}", json={"name": "New Name"}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["name"] == "New Name"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_design_missing_returns_404(client: AsyncClient, headers: dict):
|
|
||||||
res = await client.put(f"/api/v1/designs/{uuid.uuid4()}", json={"name": "X"}, headers=headers)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
# ── delete ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def test_delete_last_design_blocked(client: AsyncClient, headers: dict):
|
|
||||||
design = await _create(client, headers, name="Only One")
|
|
||||||
res = await client.delete(f"/api/v1/designs/{design['id']}", headers=headers)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_design_missing_returns_404(client: AsyncClient, headers: dict):
|
|
||||||
# Need >1 design so we get past nothing; 404 path is checked before the count guard.
|
|
||||||
await _create(client, headers, name="Keep")
|
|
||||||
res = await client.delete(f"/api/v1/designs/{uuid.uuid4()}", headers=headers)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_design_removes_its_nodes_edges_and_canvas(client: AsyncClient, headers: dict):
|
|
||||||
keep = await _create(client, headers, name="Keep")
|
|
||||||
victim = await _create(client, headers, name="Victim")
|
|
||||||
|
|
||||||
# Populate the victim design with nodes + an edge via canvas save.
|
|
||||||
n1 = node_payload(label="A")
|
|
||||||
n2 = node_payload(label="B")
|
|
||||||
e1 = edge_payload(n1["id"], n2["id"])
|
|
||||||
save = await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [n1, n2], "edges": [e1], "viewport": {}, "design_id": victim["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert save.status_code == 200
|
|
||||||
|
|
||||||
# Populate the kept design too, to prove scoping.
|
|
||||||
k1 = node_payload(label="K")
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/canvas/save",
|
|
||||||
json={"nodes": [k1], "edges": [], "viewport": {}, "design_id": keep["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
res = await client.delete(f"/api/v1/designs/{victim['id']}", headers=headers)
|
|
||||||
assert res.status_code == 204
|
|
||||||
|
|
||||||
# Victim gone from list.
|
|
||||||
listed = (await client.get("/api/v1/designs", headers=headers)).json()
|
|
||||||
assert [d["id"] for d in listed] == [keep["id"]]
|
|
||||||
|
|
||||||
# Kept design's node survives untouched.
|
|
||||||
kept_canvas = (await client.get("/api/v1/canvas", params={"design_id": keep["id"]}, headers=headers)).json()
|
|
||||||
assert len(kept_canvas["nodes"]) == 1
|
|
||||||
assert kept_canvas["nodes"][0]["label"] == "K"
|
|
||||||
+7
-205
@@ -2,6 +2,13 @@ import pytest
|
|||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def two_nodes(client: AsyncClient, headers: dict):
|
async def two_nodes(client: AsyncClient, headers: dict):
|
||||||
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
|
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
|
||||||
@@ -84,107 +91,12 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
|
|||||||
assert res.json()["path_style"] == "smooth"
|
assert res.json()["path_style"] == "smooth"
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_with_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["line_style"] == "dotted"
|
|
||||||
assert res.json()["width_mult"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_edge_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
||||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"line_style": "dashed", "width_mult": 2}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["line_style"] == "dashed"
|
|
||||||
assert res.json()["width_mult"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_defaults_line_style_none(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["line_style"] is None
|
|
||||||
assert res.json()["width_mult"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["marker_start"] == "diamond"
|
|
||||||
assert res.json()["marker_end"] == "arrow"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["marker_start"] == "none"
|
|
||||||
assert res.json()["marker_end"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["marker_end"] == "arrow"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["marker_end"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
|
|
||||||
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["marker_end"] == "circle"
|
|
||||||
assert res.json()["marker_start"] == "none"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
|
||||||
src, tgt = two_nodes
|
src, tgt = two_nodes
|
||||||
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
# Regression for #225: MCP create_edge sent no design_id, so edges were
|
|
||||||
# persisted with design_id=null and never rendered until a restart.
|
|
||||||
src, tgt = two_nodes
|
|
||||||
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
|
||||||
design_id = design.json()["id"]
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges",
|
|
||||||
json={"source": src, "target": tgt, "type": "ethernet"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["design_id"] == design_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_respects_explicit_design_id(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
src, tgt = two_nodes
|
|
||||||
await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
|
||||||
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
|
||||||
second_id = second.json()["id"]
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges",
|
|
||||||
json={"source": src, "target": tgt, "type": "ethernet", "design_id": second_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["design_id"] == second_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
async def test_create_cluster_edge_with_handles(client: AsyncClient, headers: dict, two_nodes):
|
||||||
src, tgt = two_nodes
|
src, tgt = two_nodes
|
||||||
res = await client.post(
|
res = await client.post(
|
||||||
@@ -220,113 +132,3 @@ async def test_source_and_target_handle_persist_through_update(client: AsyncClie
|
|||||||
assert data["source_handle"] == "cluster-right"
|
assert data["source_handle"] == "cluster-right"
|
||||||
assert data["target_handle"] == "cluster-left"
|
assert data["target_handle"] == "cluster-left"
|
||||||
assert data["label"] == "corosync"
|
assert data["label"] == "corosync"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
|
|
||||||
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def stacked_nodes(client: AsyncClient, headers: dict):
|
|
||||||
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
|
|
||||||
top = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
bottom = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
return top["id"], bottom["id"]
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
|
|
||||||
"""Source above target -> downstream flow: exit bottom, enter top-t."""
|
|
||||||
top, bottom = stacked_nodes
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert data["source_handle"] == "bottom"
|
|
||||||
assert data["target_handle"] == "top-t"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
|
|
||||||
"""Source below target -> upstream flow: exit top, enter bottom-t."""
|
|
||||||
top, bottom = stacked_nodes
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert data["source_handle"] == "top"
|
|
||||||
assert data["target_handle"] == "bottom-t"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
|
|
||||||
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
|
|
||||||
src, tgt = two_nodes
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert data["source_handle"] == "bottom"
|
|
||||||
assert data["target_handle"] == "top-t"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
|
|
||||||
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
|
|
||||||
top, bottom = stacked_nodes
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges",
|
|
||||||
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert data["source_handle"] == "cluster-right"
|
|
||||||
assert data["target_handle"] == "top-t"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
|
|
||||||
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
|
|
||||||
container still resolves as downstream of a node above the container."""
|
|
||||||
parent = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
child = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
below = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert data["source_handle"] == "bottom"
|
|
||||||
assert data["target_handle"] == "top-t"
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.fingerprint import (
|
from app.services.fingerprint import fingerprint_ports, match_port, suggest_node_type
|
||||||
fingerprint_ports,
|
|
||||||
match_port,
|
|
||||||
match_service,
|
|
||||||
suggest_node_type,
|
|
||||||
suggest_type_from_mac,
|
|
||||||
)
|
|
||||||
|
|
||||||
MOCK_SIGNATURES = [
|
MOCK_SIGNATURES = [
|
||||||
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
{"port": 80, "protocol": "tcp", "banner_regex": None, "service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
||||||
@@ -137,199 +131,3 @@ def test_suggest_node_type_camera_from_signature():
|
|||||||
]):
|
]):
|
||||||
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
|
||||||
assert result == "camera"
|
assert result == "camera"
|
||||||
|
|
||||||
|
|
||||||
# ── IoT detection ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_suggest_node_type_iot_from_mqtt_port():
|
|
||||||
result = suggest_node_type([{"port": 1883, "protocol": "tcp"}])
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_iot_from_coap_port():
|
|
||||||
result = suggest_node_type([{"port": 5683, "protocol": "tcp"}])
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_iot_from_esphome_port():
|
|
||||||
result = suggest_node_type([{"port": 6052, "protocol": "tcp"}])
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_shelly_mac_overrides_http_port():
|
|
||||||
# Shelly exposes port 80 (would suggest "server") but MAC identifies it as IoT
|
|
||||||
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="34:94:54:aa:bb:cc")
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_espressif_mac_returns_iot():
|
|
||||||
result = suggest_node_type([], mac="a0:20:a6:11:22:33")
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_tuya_mac_returns_iot():
|
|
||||||
result = suggest_node_type([{"port": 80, "protocol": "tcp"}], mac="d8:f1:5b:aa:bb:cc")
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_iot_wins_over_server_when_mqtt_present():
|
|
||||||
# MQTT port + HTTP port → iot wins (iot is higher priority than server now)
|
|
||||||
result = suggest_node_type([
|
|
||||||
{"port": 80, "protocol": "tcp"},
|
|
||||||
{"port": 1883, "protocol": "tcp"},
|
|
||||||
])
|
|
||||||
assert result == "iot"
|
|
||||||
|
|
||||||
|
|
||||||
# ── OUI vendor detection ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_mikrotik_returns_router():
|
|
||||||
# The motivating case: MikroTik MAC should be recognized as a router
|
|
||||||
assert suggest_type_from_mac("4c:5e:0c:11:22:33") == "router"
|
|
||||||
assert suggest_type_from_mac("b8:69:f4:aa:bb:cc") == "router"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_ubiquiti_returns_ap():
|
|
||||||
# Ubiquiti makes routers, switches, APs, cameras — most homelab gear is UniFi APs,
|
|
||||||
# so OUI defaults to "ap". Port hints can still upgrade to "router" if BGP/VPN open.
|
|
||||||
assert suggest_type_from_mac("24:a4:3c:11:22:33") == "ap"
|
|
||||||
assert suggest_type_from_mac("fc:ec:da:aa:bb:cc") == "ap"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_synology_returns_nas():
|
|
||||||
assert suggest_type_from_mac("00:11:32:11:22:33") == "nas"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_qnap_returns_nas():
|
|
||||||
assert suggest_type_from_mac("24:5e:be:aa:bb:cc") == "nas"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_hikvision_returns_camera():
|
|
||||||
assert suggest_type_from_mac("28:57:be:11:22:33") == "camera"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_dahua_returns_camera():
|
|
||||||
assert suggest_type_from_mac("3c:ef:8c:aa:bb:cc") == "camera"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_cisco_returns_switch():
|
|
||||||
assert suggest_type_from_mac("b8:38:61:11:22:33") == "switch"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_raspberry_pi_returns_server():
|
|
||||||
assert suggest_type_from_mac("b8:27:eb:11:22:33") == "server"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_handles_uppercase():
|
|
||||||
# MACs may arrive in any case; lookup must be case-insensitive
|
|
||||||
assert suggest_type_from_mac("4C:5E:0C:11:22:33") == "router"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_type_from_mac_unknown_oui_returns_none():
|
|
||||||
assert suggest_type_from_mac("00:00:01:11:22:33") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_mikrotik_mac_returns_router_no_ports():
|
|
||||||
# MikroTik device with no scanned ports should still be classified as router via MAC
|
|
||||||
assert suggest_node_type([], mac="4c:5e:0c:11:22:33") == "router"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_synology_mac_with_http_returns_nas():
|
|
||||||
# NAS priority beats server, so a Synology MAC + open HTTP → nas
|
|
||||||
result = suggest_node_type(
|
|
||||||
[{"port": 80, "protocol": "tcp"}],
|
|
||||||
mac="00:11:32:11:22:33",
|
|
||||||
)
|
|
||||||
assert result == "nas"
|
|
||||||
|
|
||||||
|
|
||||||
def test_suggest_node_type_ubiquiti_mac_with_bgp_upgrades_to_router():
|
|
||||||
# Ubiquiti OUI suggests "ap", but BGP port hint upgrades to "router" (higher priority)
|
|
||||||
result = suggest_node_type(
|
|
||||||
[{"port": 179, "protocol": "tcp"}],
|
|
||||||
mac="24:a4:3c:11:22:33",
|
|
||||||
)
|
|
||||||
assert result == "router"
|
|
||||||
|
|
||||||
|
|
||||||
# ── match_service: HTTP probe + port-agnostic ──────────────────────────────────
|
|
||||||
|
|
||||||
HTTP_SIGNATURES = [
|
|
||||||
# Generic web fallback on 8096 (port-only guess)
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": None,
|
|
||||||
"service_name": "HTTP", "icon": "🌐", "category": "web", "suggested_node_type": "server"},
|
|
||||||
# Same port, but confirmed by HTML title → should win when probe confirms
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
|
||||||
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server"},
|
|
||||||
# Port-agnostic: matches on HTTP content regardless of port
|
|
||||||
{"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "Portainer",
|
|
||||||
"service_name": "Portainer", "icon": "🐳", "category": "container", "suggested_node_type": "server"},
|
|
||||||
# Banner-based entry, no http
|
|
||||||
{"port": 9090, "protocol": "tcp", "banner_regex": "prometheus", "http_regex": None,
|
|
||||||
"service_name": "Prometheus", "icon": "🔥", "category": "monitoring", "suggested_node_type": "server"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def http_signatures():
|
|
||||||
with patch("app.services.fingerprint._load", return_value=HTTP_SIGNATURES):
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_regex_confirmed_beats_port_only(http_signatures):
|
|
||||||
# Probe ran and title matches → Jellyfin (tier 1) beats generic HTTP (tier 4)
|
|
||||||
sig = match_service(8096, "tcp", banner=None,
|
|
||||||
http_signals={"title": "Jellyfin", "headers": {}})
|
|
||||||
assert sig["service_name"] == "Jellyfin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_regex_matches_on_header(http_signatures):
|
|
||||||
sig = match_service(8096, "tcp", banner=None,
|
|
||||||
http_signals={"title": None, "headers": {"Server": "Jellyfin"}})
|
|
||||||
assert sig["service_name"] == "Jellyfin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_http_regex_miss_falls_back_to_port_only(http_signatures):
|
|
||||||
# Probe ran but nothing matched the http_regex → generic port-only entry wins
|
|
||||||
sig = match_service(8096, "tcp", banner=None,
|
|
||||||
http_signals={"title": "Some Other App", "headers": {}})
|
|
||||||
assert sig["service_name"] == "HTTP"
|
|
||||||
|
|
||||||
|
|
||||||
def test_probe_disabled_ignores_http_regex(http_signatures):
|
|
||||||
# http_signals=None (deep scan off) → http_regex entry degrades to port-only,
|
|
||||||
# generic entry (listed first) wins — identical to pre-probe behaviour.
|
|
||||||
sig = match_service(8096, "tcp", banner=None, http_signals=None)
|
|
||||||
assert sig["service_name"] == "HTTP"
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_agnostic_match_on_custom_port(http_signatures):
|
|
||||||
# Portainer found on a non-standard port, recognised purely by HTTP content
|
|
||||||
sig = match_service(54321, "tcp", banner=None,
|
|
||||||
http_signals={"title": "Portainer", "headers": {}})
|
|
||||||
assert sig["service_name"] == "Portainer"
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_agnostic_requires_probe(http_signatures):
|
|
||||||
# Same custom port, probe off → no signal → no match
|
|
||||||
assert match_service(54321, "tcp", banner=None, http_signals=None) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_banner_match_still_works_with_probe(http_signatures):
|
|
||||||
sig = match_service(9090, "tcp", banner="prometheus 2.x",
|
|
||||||
http_signals={"title": "x", "headers": {}})
|
|
||||||
assert sig["service_name"] == "Prometheus"
|
|
||||||
|
|
||||||
|
|
||||||
def test_match_port_alias_has_no_http(http_signatures):
|
|
||||||
# match_port() is the probe-less alias → http_regex entry degrades to port-only
|
|
||||||
sig = match_port(8096, "tcp")
|
|
||||||
assert sig["service_name"] == "HTTP"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fingerprint_ports_uses_http_signals(http_signatures):
|
|
||||||
results = fingerprint_ports([
|
|
||||||
{"port": 8096, "protocol": "tcp", "banner": None,
|
|
||||||
"http_signals": {"title": "Jellyfin", "headers": {}}},
|
|
||||||
])
|
|
||||||
assert results[0]["service_name"] == "Jellyfin"
|
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
"""Tests for the HTTP probe used by deep-scan service identification."""
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.http_probe import (
|
|
||||||
_extract_title,
|
|
||||||
probe_open_ports,
|
|
||||||
probe_port,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _response(text: str = "", headers: dict | None = None, status: int = 200) -> httpx.Response:
|
|
||||||
return httpx.Response(status_code=status, text=text, headers=headers or {})
|
|
||||||
|
|
||||||
|
|
||||||
# ── _extract_title ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def test_extract_title_basic():
|
|
||||||
assert _extract_title("<html><title>Jellyfin</title></html>") == "Jellyfin"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_title_collapses_whitespace():
|
|
||||||
assert _extract_title("<title>\n My App\n</title>") == "My App"
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_title_missing():
|
|
||||||
assert _extract_title("<html><body>no title</body></html>") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_title_case_insensitive():
|
|
||||||
assert _extract_title("<TITLE>Portainer</TITLE>") == "Portainer"
|
|
||||||
|
|
||||||
|
|
||||||
# ── probe_port ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_reads_title():
|
|
||||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response("<title>Jellyfin</title>"))):
|
|
||||||
result = await probe_port("10.0.0.5", 8096)
|
|
||||||
assert result == {"title": "Jellyfin", "headers": {}}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_reads_headers():
|
|
||||||
resp = _response("", headers={"Server": "nginx", "X-Powered-By": "Express"})
|
|
||||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=resp)):
|
|
||||||
result = await probe_port("10.0.0.5", 3000)
|
|
||||||
assert result["headers"] == {"Server": "nginx", "X-Powered-By": "Express"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_falls_back_to_http():
|
|
||||||
# https raises, http succeeds
|
|
||||||
calls = {"n": 0}
|
|
||||||
|
|
||||||
async def fake_get(self, url, **kw):
|
|
||||||
calls["n"] += 1
|
|
||||||
if url.startswith("https"):
|
|
||||||
raise httpx.ConnectError("tls fail")
|
|
||||||
return _response("<title>HTTP App</title>")
|
|
||||||
|
|
||||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
|
||||||
result = await probe_port("10.0.0.5", 8080)
|
|
||||||
assert result["title"] == "HTTP App"
|
|
||||||
assert calls["n"] == 2 # tried https then http
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_no_signal_returns_none():
|
|
||||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=_response(""))):
|
|
||||||
result = await probe_port("10.0.0.5", 8080)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_timeout_returns_none():
|
|
||||||
with patch("httpx.AsyncClient.get", new=AsyncMock(side_effect=httpx.TimeoutException("slow"))):
|
|
||||||
result = await probe_port("10.0.0.5", 8080)
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_skips_non_http_ports():
|
|
||||||
# SSH should never trigger an HTTP request
|
|
||||||
get = AsyncMock()
|
|
||||||
with patch("httpx.AsyncClient.get", new=get):
|
|
||||||
result = await probe_port("10.0.0.5", 22)
|
|
||||||
assert result is None
|
|
||||||
get.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_port_verify_tls_flag_passed():
|
|
||||||
with patch("app.services.http_probe.httpx.AsyncClient") as client_cls:
|
|
||||||
instance = client_cls.return_value.__aenter__.return_value
|
|
||||||
instance.get = AsyncMock(return_value=_response("<title>X</title>"))
|
|
||||||
await probe_port("10.0.0.5", 8443, verify_tls=True)
|
|
||||||
assert client_cls.call_args.kwargs["verify"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ── probe_open_ports ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_probe_open_ports_enriches_each_port():
|
|
||||||
async def fake_get(self, url, **kw):
|
|
||||||
if ":8096" in url:
|
|
||||||
return _response("<title>Jellyfin</title>")
|
|
||||||
return _response("")
|
|
||||||
|
|
||||||
ports = [{"port": 8096, "protocol": "tcp"}, {"port": 9999, "protocol": "tcp"}]
|
|
||||||
with patch("httpx.AsyncClient.get", new=fake_get):
|
|
||||||
result = await probe_open_ports("10.0.0.5", ports)
|
|
||||||
|
|
||||||
by_port = {p["port"]: p for p in result}
|
|
||||||
assert by_port[8096]["http_signals"]["title"] == "Jellyfin"
|
|
||||||
assert by_port[9999]["http_signals"] is None
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for the /api/v1/liveview read-only canvas endpoint.
|
|
||||||
|
|
||||||
The endpoint is:
|
|
||||||
- Disabled by default (LIVEVIEW_KEY not set) → 403
|
|
||||||
- Returns 403 for missing or wrong key even when enabled
|
|
||||||
- Returns canvas data for a valid key (no JWT required)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def reset_liveview_key():
|
|
||||||
"""Restore liveview_key after each test so tests are isolated."""
|
|
||||||
original = settings.liveview_key
|
|
||||||
yield
|
|
||||||
settings.liveview_key = original
|
|
||||||
|
|
||||||
|
|
||||||
# ── Disabled (no key configured) ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_disabled_by_default(client: AsyncClient):
|
|
||||||
settings.liveview_key = None
|
|
||||||
res = await client.get("/api/v1/liveview?key=anything")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_disabled_when_key_empty(client: AsyncClient):
|
|
||||||
settings.liveview_key = ""
|
|
||||||
res = await client.get("/api/v1/liveview?key=anything")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Enabled but wrong / missing key ──────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_wrong_key(client: AsyncClient):
|
|
||||||
settings.liveview_key = "correct-secret"
|
|
||||||
res = await client.get("/api/v1/liveview?key=wrong-key")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert res.json()["detail"] == "Invalid live view key"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_missing_key_param(client: AsyncClient):
|
|
||||||
settings.liveview_key = "correct-secret"
|
|
||||||
res = await client.get("/api/v1/liveview")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert res.json()["detail"] == "Invalid live view key"
|
|
||||||
|
|
||||||
|
|
||||||
# ── Valid key — no JWT needed ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_valid_key_returns_canvas(client: AsyncClient):
|
|
||||||
settings.liveview_key = "my-secret-key"
|
|
||||||
res = await client.get("/api/v1/liveview?key=my-secret-key")
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert "nodes" in data
|
|
||||||
assert "edges" in data
|
|
||||||
assert "viewport" in data
|
|
||||||
assert isinstance(data["nodes"], list)
|
|
||||||
assert isinstance(data["edges"], list)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_does_not_require_jwt(client: AsyncClient):
|
|
||||||
"""Accessing without Authorization header must work when key is correct."""
|
|
||||||
settings.liveview_key = "open-sesame"
|
|
||||||
# client has no auth headers set here
|
|
||||||
res = await client.get("/api/v1/liveview?key=open-sesame")
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_returns_saved_canvas(client: AsyncClient, headers):
|
|
||||||
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
|
||||||
settings.liveview_key = "test-key"
|
|
||||||
|
|
||||||
# Save a canvas with one node
|
|
||||||
payload = {
|
|
||||||
"nodes": [{
|
|
||||||
"id": "lv-node-1",
|
|
||||||
"type": "server",
|
|
||||||
"label": "Live Node",
|
|
||||||
"status": "online",
|
|
||||||
"services": [],
|
|
||||||
"pos_x": 10,
|
|
||||||
"pos_y": 20,
|
|
||||||
}],
|
|
||||||
"edges": [],
|
|
||||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
|
||||||
}
|
|
||||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
|
||||||
|
|
||||||
# Liveview should return the same node
|
|
||||||
res = await client.get("/api/v1/liveview?key=test-key")
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = res.json()["nodes"]
|
|
||||||
assert len(nodes) == 1
|
|
||||||
assert nodes[0]["id"] == "lv-node-1"
|
|
||||||
assert nodes[0]["label"] == "Live Node"
|
|
||||||
|
|
||||||
|
|
||||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, headers):
|
|
||||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
|
||||||
settings.liveview_key = "test-key"
|
|
||||||
payload = {
|
|
||||||
"nodes": [],
|
|
||||||
"edges": [],
|
|
||||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
|
||||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
|
||||||
}
|
|
||||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
|
||||||
|
|
||||||
res = await client.get("/api/v1/liveview?key=test-key")
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body["viewport"].get("theme_id") == "matrix"
|
|
||||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
|
||||||
|
|
||||||
|
|
||||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_disabled_after_key_cleared(client: AsyncClient):
|
|
||||||
settings.liveview_key = "was-enabled"
|
|
||||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
|
||||||
assert res.status_code == 200
|
|
||||||
|
|
||||||
settings.liveview_key = None
|
|
||||||
res = await client.get("/api/v1/liveview?key=was-enabled")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert res.json()["detail"] == "Live view is disabled"
|
|
||||||
|
|
||||||
|
|
||||||
# ── /config (authenticated) — key used to build share links ──────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_config_requires_auth(client: AsyncClient):
|
|
||||||
"""The config endpoint exposes the key, so it must reject unauthenticated calls."""
|
|
||||||
settings.liveview_key = "secret"
|
|
||||||
res = await client.get("/api/v1/liveview/config")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, headers):
|
|
||||||
settings.liveview_key = "share-me"
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body == {"enabled": True, "key": "share-me"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_config_disabled_hides_key(client: AsyncClient, headers):
|
|
||||||
settings.liveview_key = None
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": False, "key": None}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_config_empty_key_disabled(client: AsyncClient, headers):
|
|
||||||
settings.liveview_key = ""
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json() == {"enabled": False, "key": None}
|
|
||||||
|
|
||||||
|
|
||||||
# ── design_id selects which canvas is rendered ───────────────────────────────
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_liveview_design_id_selects_canvas(client: AsyncClient, headers):
|
|
||||||
"""?design_id=<id> renders that design's canvas, not the first one."""
|
|
||||||
settings.liveview_key = "test-key"
|
|
||||||
|
|
||||||
# Create two designs
|
|
||||||
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
|
||||||
d2 = (await client.post("/api/v1/designs", json={"name": "Electrical"}, headers=headers)).json()
|
|
||||||
|
|
||||||
# Save a distinct node into each design
|
|
||||||
for design, node_id, label in ((d1, "n-net", "Net Node"), (d2, "n-elec", "Elec Node")):
|
|
||||||
payload = {
|
|
||||||
"nodes": [{
|
|
||||||
"id": node_id,
|
|
||||||
"type": "server",
|
|
||||||
"label": label,
|
|
||||||
"status": "online",
|
|
||||||
"services": [],
|
|
||||||
"pos_x": 0,
|
|
||||||
"pos_y": 0,
|
|
||||||
}],
|
|
||||||
"edges": [],
|
|
||||||
"viewport": {"x": 0, "y": 0, "zoom": 1},
|
|
||||||
"design_id": design["id"],
|
|
||||||
}
|
|
||||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
|
||||||
|
|
||||||
# Requesting d2 returns only the electrical node
|
|
||||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d2['id']}")
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = res.json()["nodes"]
|
|
||||||
assert [n["id"] for n in nodes] == ["n-elec"]
|
|
||||||
|
|
||||||
# Requesting d1 returns only the network node
|
|
||||||
res = await client.get(f"/api/v1/liveview?key=test-key&design_id={d1['id']}")
|
|
||||||
assert res.status_code == 200
|
|
||||||
nodes = res.json()["nodes"]
|
|
||||||
assert [n["id"] for n in nodes] == ["n-net"]
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Unit tests for MAC normalization (the cross-source dedup key)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.services.discovery_sources import add_source
|
|
||||||
from app.services.mac_utils import normalize_mac
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mac_lowercases_and_unifies_separators() -> None:
|
|
||||||
assert normalize_mac("BC:24:11:AA:BB:CC") == "bc:24:11:aa:bb:cc"
|
|
||||||
assert normalize_mac("bc-24-11-aa-bb-cc") == "bc:24:11:aa:bb:cc"
|
|
||||||
assert normalize_mac(" BC:24:11:AA:BB:CC ") == "bc:24:11:aa:bb:cc"
|
|
||||||
|
|
||||||
|
|
||||||
def test_normalize_mac_blank_is_none() -> None:
|
|
||||||
assert normalize_mac(None) is None
|
|
||||||
assert normalize_mac("") is None
|
|
||||||
assert normalize_mac(" ") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_add_source_unions_without_duplicates() -> None:
|
|
||||||
assert add_source(None, "arp") == ["arp"]
|
|
||||||
assert add_source(["arp"], "proxmox") == ["arp", "proxmox"]
|
|
||||||
assert add_source(["arp", "proxmox"], "proxmox") == ["arp", "proxmox"]
|
|
||||||
assert add_source(["arp"], None) == ["arp"]
|
|
||||||
# Drops falsy members already present.
|
|
||||||
assert add_source(["arp", ""], "proxmox") == ["arp", "proxmox"]
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.api.routes import media
|
|
||||||
from app.core.config import settings
|
|
||||||
|
|
||||||
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def media_dir(tmp_path, monkeypatch):
|
|
||||||
"""Point uploads at a temp folder for the duration of a test."""
|
|
||||||
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
|
||||||
return tmp_path
|
|
||||||
|
|
||||||
|
|
||||||
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
|
|
||||||
return await client.post(
|
|
||||||
"/api/v1/media/upload",
|
|
||||||
files={"file": (name, data, content_type)},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_requires_auth(client, media_dir):
|
|
||||||
res = await _upload(client, headers={})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_stores_file_and_returns_url(client, headers, media_dir):
|
|
||||||
res = await _upload(client, headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
|
|
||||||
# File written to disk with the server-generated name (client name ignored).
|
|
||||||
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
|
|
||||||
assert body["filename"] != "plan.png"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_rejects_unsupported_type(client, headers, media_dir):
|
|
||||||
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
|
|
||||||
assert res.status_code == 415
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_rejects_content_type_magic_mismatch(client, headers, media_dir):
|
|
||||||
# Claims PNG but bytes are not a PNG.
|
|
||||||
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
|
|
||||||
assert res.status_code == 415
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_upload_rejects_oversize(client, headers, media_dir, monkeypatch):
|
|
||||||
monkeypatch.setattr(media, "MAX_BYTES", 8)
|
|
||||||
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
|
|
||||||
assert res.status_code == 413
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_serves_uploaded_file(client, headers, media_dir):
|
|
||||||
up = await _upload(client, headers)
|
|
||||||
res = await client.get(up.json()["url"]) # public, no auth
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.content == PNG_BYTES
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_rejects_bad_filename(client, media_dir):
|
|
||||||
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_rejects_bad_filename(client, headers, media_dir):
|
|
||||||
res = await client.delete("/api/v1/media/..%2f..%2fetc%2fpasswd", headers=headers)
|
|
||||||
assert res.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_delete_requires_auth_and_removes_file(client, headers, media_dir):
|
|
||||||
up = await _upload(client, headers)
|
|
||||||
filename = up.json()["filename"]
|
|
||||||
|
|
||||||
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
|
|
||||||
assert (media_dir / filename).exists()
|
|
||||||
|
|
||||||
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
|
|
||||||
assert res.status_code == 204
|
|
||||||
assert not (media_dir / filename).exists()
|
|
||||||
assert (await client.get(up.json()["url"])).status_code == 404
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""Unit tests for the shared MQTT helpers in mqtt_common."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import ssl
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.mqtt_common import (
|
|
||||||
_build_tls_context,
|
|
||||||
_sanitize_mqtt_error,
|
|
||||||
request_response,
|
|
||||||
)
|
|
||||||
from app.services.mqtt_common import test_connection as _test_connection
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _sanitize_mqtt_error — never leak credentials
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_auth_error() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(Exception("Not authorized: bad username for user=admin pwd=secret"))
|
|
||||||
assert msg == "Authentication failed"
|
|
||||||
assert "secret" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_refused() -> None:
|
|
||||||
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_dns() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(Exception("nodename nor servname provided: broker.lan"))
|
|
||||||
assert msg == "Broker hostname could not be resolved"
|
|
||||||
assert "broker.lan" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_tls() -> None:
|
|
||||||
assert _sanitize_mqtt_error(
|
|
||||||
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
|
||||||
) == "TLS handshake failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_timeout() -> None:
|
|
||||||
assert _sanitize_mqtt_error(Exception("operation timed out")) == "Connection to broker timed out"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_unknown_falls_back() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker weird"))
|
|
||||||
assert msg == "MQTT connection failed"
|
|
||||||
assert "hunter2" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _build_tls_context
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_tls_secure_verifies() -> None:
|
|
||||||
ctx = _build_tls_context(insecure=False)
|
|
||||||
assert ctx.check_hostname is True
|
|
||||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
||||||
|
|
||||||
|
|
||||||
def test_tls_insecure_disables_verification() -> None:
|
|
||||||
ctx = _build_tls_context(insecure=True)
|
|
||||||
assert ctx.check_hostname is False
|
|
||||||
assert ctx.verify_mode == ssl.CERT_NONE
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# request_response (mocked aiomqtt)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_SAMPLE = {"success": True, "result": []}
|
|
||||||
|
|
||||||
|
|
||||||
def _fake_client_factory(topic: str, payload: dict):
|
|
||||||
class _FakeMessage:
|
|
||||||
_yielded = False
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.topic = topic
|
|
||||||
self.payload = json.dumps(payload).encode()
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __anext__(self):
|
|
||||||
if self._yielded:
|
|
||||||
raise StopAsyncIteration
|
|
||||||
self._yielded = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def subscribe(self, _topic: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def publish(self, _topic: str, _payload: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def messages(self):
|
|
||||||
return _FakeMessage()
|
|
||||||
|
|
||||||
return _FakeClient
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_request_response_success() -> None:
|
|
||||||
topic = "zwave/_CLIENTS/ZWAVE_GATEWAY-zwavejs2mqtt/api/getNodes"
|
|
||||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
out = await request_response(
|
|
||||||
"localhost", 1883, "req/topic", topic, {"args": []}
|
|
||||||
)
|
|
||||||
assert out == _SAMPLE
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_request_response_connection_error() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
raise Exception("Connection refused")
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
with pytest.raises(ConnectionError):
|
|
||||||
await request_response("bad", 1883, "req", "resp", {})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_request_response_passes_tls_context() -> None:
|
|
||||||
topic = "resp"
|
|
||||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _fake_client_factory(topic, _SAMPLE)()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
await request_response("h", 8883, "req", topic, {}, tls=True, tls_insecure=True)
|
|
||||||
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
|
||||||
assert ctx.verify_mode == ssl.CERT_NONE
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_success() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
assert await _test_connection("localhost", 1883) is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_failure() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
raise Exception("refused")
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.mqtt_common.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
with pytest.raises(ConnectionError):
|
|
||||||
await _test_connection("bad", 1883)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.db.models import Design, Edge, Node
|
|
||||||
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
|
||||||
|
|
||||||
|
|
||||||
async def _design(db, name="d1"):
|
|
||||||
d = Design(name=name)
|
|
||||||
db.add(d)
|
|
||||||
await db.flush()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_collapses_same_ieee_same_design(db_session):
|
|
||||||
d = await _design(db_session)
|
|
||||||
keep = Node(
|
|
||||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
|
||||||
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
|
|
||||||
pos_x=100, pos_y=200,
|
|
||||||
)
|
|
||||||
db_session.add(keep)
|
|
||||||
await db_session.flush()
|
|
||||||
dup = Node(
|
|
||||||
label="Sensor", type="zigbee_enddevice", design_id=d.id,
|
|
||||||
ieee_address="0xAAA", ip="10.0.0.5",
|
|
||||||
properties=[{"key": "LQI", "value": "88", "visible": False}],
|
|
||||||
)
|
|
||||||
db_session.add(dup)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
removed = await dedupe_nodes_by_ieee(db_session)
|
|
||||||
assert removed == 1
|
|
||||||
|
|
||||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
|
|
||||||
assert len(nodes) == 1
|
|
||||||
survivor = nodes[0]
|
|
||||||
assert survivor.id == keep.id # oldest kept
|
|
||||||
assert survivor.pos_x == 100 # canvas position preserved
|
|
||||||
assert survivor.ip == "10.0.0.5" # missing field filled from dup
|
|
||||||
keys = {p["key"] for p in survivor.properties}
|
|
||||||
assert keys == {"IEEE", "LQI"} # properties merged
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preserves_same_ieee_across_designs(db_session):
|
|
||||||
"""Same device on two canvases is valid — must NOT be merged."""
|
|
||||||
d1 = await _design(db_session, "d1")
|
|
||||||
d2 = await _design(db_session, "d2")
|
|
||||||
for d in (d1, d2):
|
|
||||||
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, ieee_address="0xBBB"))
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
removed = await dedupe_nodes_by_ieee(db_session)
|
|
||||||
assert removed == 0
|
|
||||||
|
|
||||||
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
|
|
||||||
assert len(nodes) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repoints_edges_and_drops_dupes(db_session):
|
|
||||||
d = await _design(db_session)
|
|
||||||
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
|
||||||
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
|
|
||||||
other = Node(label="B", type="server", design_id=d.id)
|
|
||||||
db_session.add_all([keep, other])
|
|
||||||
await db_session.flush()
|
|
||||||
db_session.add(dup)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
# keep<->other and dup<->other (parallel after repoint), plus dup<->keep (self-loop).
|
|
||||||
db_session.add_all([
|
|
||||||
Edge(source=keep.id, target=other.id, type="ethernet", design_id=d.id),
|
|
||||||
Edge(source=dup.id, target=other.id, type="ethernet", design_id=d.id),
|
|
||||||
Edge(source=dup.id, target=keep.id, type="ethernet", design_id=d.id),
|
|
||||||
])
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
removed = await dedupe_nodes_by_ieee(db_session)
|
|
||||||
assert removed == 1
|
|
||||||
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
# self-loop dropped, parallel edge collapsed -> a single keep<->other edge
|
|
||||||
assert len(edges) == 1
|
|
||||||
e = edges[0]
|
|
||||||
assert {e.source, e.target} == {keep.id, other.id}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repoints_child_parent(db_session):
|
|
||||||
d = await _design(db_session)
|
|
||||||
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
|
||||||
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
|
|
||||||
db_session.add(keep)
|
|
||||||
await db_session.flush()
|
|
||||||
db_session.add(dup)
|
|
||||||
await db_session.flush()
|
|
||||||
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
|
|
||||||
db_session.add(child)
|
|
||||||
await db_session.flush()
|
|
||||||
|
|
||||||
await dedupe_nodes_by_ieee(db_session)
|
|
||||||
await db_session.refresh(child)
|
|
||||||
assert child.parent_id == keep.id
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_idempotent_noop_when_unique(db_session):
|
|
||||||
d = await _design(db_session)
|
|
||||||
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
|
|
||||||
await db_session.flush()
|
|
||||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
|
||||||
assert await dedupe_nodes_by_ieee(db_session) == 0
|
|
||||||
+8
-256
@@ -1,6 +1,14 @@
|
|||||||
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
|
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
|
||||||
res = await client.get("/api/v1/nodes", headers=headers)
|
res = await client.get("/api/v1/nodes", headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
@@ -59,91 +67,6 @@ async def test_update_node_not_found(client: AsyncClient, headers: dict):
|
|||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_without_design_id_falls_back_to_first_design(client: AsyncClient, headers: dict):
|
|
||||||
# Regression for #225: MCP create_node sent no design_id, so nodes were
|
|
||||||
# persisted with design_id=null and never rendered on the canvas until a
|
|
||||||
# container restart reconciled them. They must attach to a design on create.
|
|
||||||
design = await client.post("/api/v1/designs", json={"name": "Primary"}, headers=headers)
|
|
||||||
design_id = design.json()["id"]
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "generic", "label": "mcp-node", "ip": "192.168.18.99"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["design_id"] == design_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_respects_explicit_design_id(client: AsyncClient, headers: dict):
|
|
||||||
# When a design_id is supplied it must win over the first-design fallback.
|
|
||||||
first = await client.post("/api/v1/designs", json={"name": "First"}, headers=headers)
|
|
||||||
second = await client.post("/api/v1/designs", json={"name": "Second"}, headers=headers)
|
|
||||||
second_id = second.json()["id"]
|
|
||||||
assert first.json()["id"] != second_id
|
|
||||||
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "generic", "label": "n", "design_id": second_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["design_id"] == second_id
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_rejects_duplicate_ip_on_same_design(client: AsyncClient, headers: dict):
|
|
||||||
# A second node with the same ip on the same design is a silent duplicate —
|
|
||||||
# scripts/MCP clients get 409 with the existing node id instead. (#260)
|
|
||||||
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
|
|
||||||
design_id = design.json()["id"]
|
|
||||||
first = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert first.status_code == 201
|
|
||||||
existing_id = first.json()["id"]
|
|
||||||
|
|
||||||
dup = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "srv-again", "ip": "192.168.1.5", "design_id": design_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert dup.status_code == 409
|
|
||||||
detail = dup.json()["detail"]
|
|
||||||
assert detail["duplicate"] is True
|
|
||||||
assert detail["existing_node_id"] == existing_id
|
|
||||||
assert detail["match"] == "ip"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_force_bypasses_duplicate_guard(client: AsyncClient, headers: dict):
|
|
||||||
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
|
|
||||||
design_id = design.json()["id"]
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
forced = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id, "force": True},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert forced.status_code == 201
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_without_any_design_stays_null(client: AsyncClient, headers: dict):
|
|
||||||
# No designs exist yet: fallback can't invent one, so design_id stays null
|
|
||||||
# rather than erroring.
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "generic", "label": "orphan"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["design_id"] is None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
|
async def test_delete_node_not_found(client: AsyncClient, headers: dict):
|
||||||
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
|
res = await client.delete("/api/v1/nodes/nonexistent", headers=headers)
|
||||||
assert res.status_code == 404
|
assert res.status_code == 404
|
||||||
@@ -192,174 +115,3 @@ async def test_update_node_parent_id(client: AsyncClient, headers: dict):
|
|||||||
async def test_create_node_requires_auth(client: AsyncClient):
|
async def test_create_node_requires_auth(client: AsyncClient):
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "N", "status": "unknown"})
|
||||||
assert res.status_code == 401
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
# --- Properties tests ---
|
|
||||||
|
|
||||||
async def test_create_node_default_properties_empty(client: AsyncClient, headers: dict):
|
|
||||||
"""New node has an empty properties list by default."""
|
|
||||||
res = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["properties"] == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_with_properties(client: AsyncClient, headers: dict):
|
|
||||||
"""Node created with properties round-trips correctly."""
|
|
||||||
props = [
|
|
||||||
{"key": "CPU Model", "value": "i7-12700K", "icon": "Cpu", "visible": True},
|
|
||||||
{"key": "RAM", "value": "32 GB", "icon": "MemoryStick", "visible": False},
|
|
||||||
]
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert res.json()["properties"] == props
|
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_node_properties(client: AsyncClient, headers: dict):
|
|
||||||
"""PATCH with properties replaces the full properties array."""
|
|
||||||
create = await client.post("/api/v1/nodes", json={"type": "server", "label": "Srv", "status": "unknown"}, headers=headers)
|
|
||||||
node_id = create.json()["id"]
|
|
||||||
|
|
||||||
props = [{"key": "Disk", "value": "2 TB", "icon": "HardDrive", "visible": True}]
|
|
||||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": props}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["properties"] == props
|
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_node_without_properties_does_not_wipe(client: AsyncClient, headers: dict):
|
|
||||||
"""PATCH that omits properties leaves existing properties untouched."""
|
|
||||||
props = [{"key": "GPU", "value": "RTX 4090", "icon": "Monitor", "visible": True}]
|
|
||||||
create = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
node_id = create.json()["id"]
|
|
||||||
|
|
||||||
# PATCH only the label — properties must survive
|
|
||||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"label": "Updated"}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["properties"] == props
|
|
||||||
assert res.json()["label"] == "Updated"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_patch_node_clears_properties_with_empty_array(client: AsyncClient, headers: dict):
|
|
||||||
"""PATCH with properties=[] explicitly clears all properties."""
|
|
||||||
props = [{"key": "CPU Model", "value": "i5", "icon": "Cpu", "visible": True}]
|
|
||||||
create = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
node_id = create.json()["id"]
|
|
||||||
|
|
||||||
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["properties"] == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_get_node_returns_properties(client: AsyncClient, headers: dict):
|
|
||||||
"""GET /nodes/:id returns the properties field."""
|
|
||||||
props = [{"key": "OS", "value": "Debian 12", "icon": "Server", "visible": True}]
|
|
||||||
create = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "Srv", "status": "unknown", "properties": props},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
node_id = create.json()["id"]
|
|
||||||
|
|
||||||
res = await client.get(f"/api/v1/nodes/{node_id}", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["properties"] == props
|
|
||||||
|
|
||||||
|
|
||||||
async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
|
||||||
"""A property with icon=null is valid and round-trips correctly."""
|
|
||||||
props = [{"key": "Notes", "value": "custom value", "icon": None, "visible": False}]
|
|
||||||
create = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "generic", "label": "G", "status": "unknown", "properties": props},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert create.status_code == 201
|
|
||||||
assert create.json()["properties"] == props
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Auto-positioning (issue #265): omitting pos_x/pos_y snaps the node to the
|
|
||||||
# first free 200x100 grid slot instead of stacking everything at (0, 0).
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_auto_positions_first_at_origin(client: AsyncClient, headers: dict):
|
|
||||||
"""First root node with no coordinates lands at (0, 0)."""
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_auto_position_avoids_collision(client: AsyncClient, headers: dict):
|
|
||||||
"""A second auto-placed root node takes the next free grid cell, not (0, 0)."""
|
|
||||||
first = await client.post(
|
|
||||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
|
||||||
)
|
|
||||||
assert (first.json()["pos_x"], first.json()["pos_y"]) == (0.0, 0.0)
|
|
||||||
|
|
||||||
second = await client.post(
|
|
||||||
"/api/v1/nodes", json={"type": "server", "label": "B", "status": "unknown"}, headers=headers
|
|
||||||
)
|
|
||||||
assert second.status_code == 201
|
|
||||||
# Next free cell in row 0 is column 1 -> x = 1 * 200.
|
|
||||||
assert (second.json()["pos_x"], second.json()["pos_y"]) == (200.0, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_child_defaults_to_parent_origin(client: AsyncClient, headers: dict):
|
|
||||||
"""A child node (parent_id set) with no coordinates defaults to (0, 0) relative to its parent."""
|
|
||||||
parent = (
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
).json()
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "vm", "label": "VM1", "status": "online", "parent_id": parent["id"]},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_explicit_position_preserved(client: AsyncClient, headers: dict):
|
|
||||||
"""Explicit coordinates are honored, never auto-placed."""
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "A", "status": "unknown", "pos_x": 512.0, "pos_y": 384.0},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
data = res.json()
|
|
||||||
assert (data["pos_x"], data["pos_y"]) == (512.0, 384.0)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_create_node_explicit_zero_position_preserved(client: AsyncClient, headers: dict):
|
|
||||||
"""pos=0 is an explicit value, not 'omitted' — auto-position must not treat 0 as None."""
|
|
||||||
await client.post(
|
|
||||||
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
|
|
||||||
)
|
|
||||||
# Second node explicitly pinned to (0, 0) even though the cell is taken.
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/nodes",
|
|
||||||
json={"type": "server", "label": "B", "status": "unknown", "pos_x": 0, "pos_y": 0},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 201
|
|
||||||
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
|
|
||||||
|
|||||||
@@ -1,489 +0,0 @@
|
|||||||
"""API + persistence tests for /api/v1/proxmox/*."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.proxmox import (
|
|
||||||
_background_proxmox_import,
|
|
||||||
_guest_visibility_advisory,
|
|
||||||
_persist_pending_import,
|
|
||||||
)
|
|
||||||
from app.api.routes.scan import _is_proxmox_cluster_member, _resolve_pending_links_for_ieee
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _clear_env_token():
|
|
||||||
"""Ensure a clean token state per test; restore afterwards."""
|
|
||||||
tid, sec = settings.proxmox_token_id, settings.proxmox_token_secret
|
|
||||||
settings.proxmox_token_id = ""
|
|
||||||
settings.proxmox_token_secret = ""
|
|
||||||
yield
|
|
||||||
settings.proxmox_token_id, settings.proxmox_token_secret = tid, sec
|
|
||||||
|
|
||||||
|
|
||||||
def _host_node() -> dict:
|
|
||||||
return {
|
|
||||||
"id": "pve-node-pve1", "label": "pve1", "type": "proxmox",
|
|
||||||
"ieee_address": "pve-node-pve1", "hostname": "pve1", "ip": None,
|
|
||||||
"status": "online", "cpu_count": 8, "ram_gb": 16.0, "disk_gb": 500.0,
|
|
||||||
"vendor": "Proxmox VE", "model": None, "parent_ieee": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _guest_node(vmid: int, ip: str | None, status: str = "online", mac: str | None = None) -> dict:
|
|
||||||
return {
|
|
||||||
"id": f"pve-pve1-{vmid}", "label": f"vm{vmid}", "type": "vm",
|
|
||||||
"ieee_address": f"pve-pve1-{vmid}", "hostname": f"vm{vmid}", "ip": ip,
|
|
||||||
"mac": mac,
|
|
||||||
"status": status, "cpu_count": 2, "ram_gb": 4.0, "disk_gb": 32.0,
|
|
||||||
"vendor": "Proxmox VE", "model": "QEMU", "vmid": vmid,
|
|
||||||
"parent_ieee": "pve-node-pve1",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# --- endpoints -------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_uses_body_token(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.proxmox.test_proxmox_connection", new=AsyncMock(return_value=(True, "ok"))):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/proxmox/test-connection",
|
|
||||||
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["connected"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_token_is_rejected(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/proxmox/test-connection",
|
|
||||||
json={"host": "pve", "port": 8006},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_pending_creates_scan_run(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/proxmox/import-pending",
|
|
||||||
json={"host": "pve", "port": 8006, "token_id": "u@pam!t", "token_secret": "s"},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["kind"] == "proxmox"
|
|
||||||
assert data["status"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_creates_scan_run(client: AsyncClient, headers: dict) -> None:
|
|
||||||
settings.proxmox_host = "pve"
|
|
||||||
settings.proxmox_token_id = "u@pam!t"
|
|
||||||
settings.proxmox_token_secret = "s"
|
|
||||||
with patch("app.api.routes.proxmox._background_proxmox_import", new_callable=AsyncMock):
|
|
||||||
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["kind"] == "proxmox"
|
|
||||||
assert data["status"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_rejected_without_token(client: AsyncClient, headers: dict) -> None:
|
|
||||||
settings.proxmox_host = "pve"
|
|
||||||
# _clear_env_token leaves token empty
|
|
||||||
res = await client.post("/api/v1/proxmox/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post("/api/v1/proxmox/sync-now")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post("/api/v1/proxmox/import-pending", json={"host": "pve"})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_config_omits_token(client: AsyncClient, headers: dict) -> None:
|
|
||||||
settings.proxmox_token_id = "u@pam!t"
|
|
||||||
settings.proxmox_token_secret = "supersecret"
|
|
||||||
res = await client.get("/api/v1/proxmox/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.text
|
|
||||||
assert "supersecret" not in body
|
|
||||||
assert res.json()["token_configured"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enable_sync_without_token_rejected(client: AsyncClient, headers: dict) -> None:
|
|
||||||
# _clear_env_token leaves token empty → enabling auto-sync is rejected.
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/proxmox/config",
|
|
||||||
json={"sync_enabled": True, "sync_interval": 600},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_save_config_persists_only_sync_fields(client: AsyncClient, headers: dict) -> None:
|
|
||||||
"""Connection config is env-only: even if a client sends host/port/verify,
|
|
||||||
the endpoint ignores them and persists only the sync activation."""
|
|
||||||
settings.proxmox_host = "pve"
|
|
||||||
settings.proxmox_token_id = "u@pam!t"
|
|
||||||
settings.proxmox_token_secret = "s"
|
|
||||||
saved: dict = {}
|
|
||||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
|
||||||
host=self.proxmox_host, enabled=self.proxmox_sync_enabled, interval=self.proxmox_sync_interval
|
|
||||||
)), patch("app.api.routes.proxmox.set_proxmox_sync_enabled"), \
|
|
||||||
patch("app.api.routes.proxmox.reschedule_proxmox_sync"):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/proxmox/config",
|
|
||||||
json={"host": "attacker", "port": 1, "verify_tls": False, "sync_enabled": True, "sync_interval": 900},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
# host untouched by the request body; sync activation applied.
|
|
||||||
assert settings.proxmox_host == "pve"
|
|
||||||
assert saved == {"host": "pve", "enabled": True, "interval": 900}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_background_import_broadcasts_refresh() -> None:
|
|
||||||
"""After persisting, the import emits a scan update so an open inventory
|
|
||||||
reloads without a manual refresh (same signal the IP scan uses)."""
|
|
||||||
fake_db = AsyncMock()
|
|
||||||
fake_db.get = AsyncMock(return_value=None) # no ScanRun row → skip status update
|
|
||||||
cm = AsyncMock()
|
|
||||||
cm.__aenter__.return_value = fake_db
|
|
||||||
cm.__aexit__.return_value = False
|
|
||||||
|
|
||||||
with patch("app.api.routes.proxmox.AsyncSessionLocal", MagicMock(return_value=cm)), \
|
|
||||||
patch("app.api.routes.proxmox.fetch_proxmox_inventory", new=AsyncMock(return_value=([], []))), \
|
|
||||||
patch("app.api.routes.proxmox._persist_pending_import",
|
|
||||||
new=AsyncMock(return_value=SimpleNamespace(device_count=3))), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new=AsyncMock()) as bcast:
|
|
||||||
await _background_proxmox_import("run1", "h", 8006, "u@pam!t", "s", True)
|
|
||||||
|
|
||||||
bcast.assert_awaited_once()
|
|
||||||
assert bcast.await_args.kwargs["devices_found"] == 3
|
|
||||||
|
|
||||||
|
|
||||||
# --- guest-visibility advisory ---------------------------------------------
|
|
||||||
|
|
||||||
def test_advisory_when_hosts_only() -> None:
|
|
||||||
msg = _guest_visibility_advisory([_host_node()])
|
|
||||||
assert msg is not None
|
|
||||||
assert "PVEAuditor" in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_advisory_when_guests_present() -> None:
|
|
||||||
assert _guest_visibility_advisory([_host_node(), _guest_node(101, "10.0.0.5")]) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_advisory_when_nothing_imported() -> None:
|
|
||||||
assert _guest_visibility_advisory([]) is None
|
|
||||||
|
|
||||||
|
|
||||||
# --- persistence / dedupe --------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_creates_pending(db_session) -> None:
|
|
||||||
nodes = [_host_node(), _guest_node(101, "10.0.0.5")]
|
|
||||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
|
||||||
result = await _persist_pending_import(db_session, nodes, edges)
|
|
||||||
assert result.pending_created == 2
|
|
||||||
assert result.links_recorded == 1
|
|
||||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
|
||||||
assert {r.suggested_type for r in rows} == {"proxmox", "vm"}
|
|
||||||
# Specs carried as properties.
|
|
||||||
vm = next(r for r in rows if r.suggested_type == "vm")
|
|
||||||
assert any(p["key"] == "CPU Cores" for p in vm.properties)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
|
|
||||||
# A device previously found by an IP scan (no ieee, no specs).
|
|
||||||
scanned = Node(
|
|
||||||
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
|
|
||||||
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
|
|
||||||
)
|
|
||||||
db_session.add(scanned)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
|
||||||
|
|
||||||
# No duplicate node; identity + specs merged onto the existing one.
|
|
||||||
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
|
|
||||||
assert len(nodes) == 1
|
|
||||||
merged = nodes[0]
|
|
||||||
assert merged.ieee_address == "pve-pve1-101"
|
|
||||||
assert merged.cpu_count == 2
|
|
||||||
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
|
|
||||||
# Inventory row exists as approved (already on canvas).
|
|
||||||
inv = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
|
||||||
assert inv.status == "approved"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_merges_pending_scan_row_by_mac(db_session) -> None:
|
|
||||||
# Device previously found by an IP scan: arp source, MAC known, no ieee.
|
|
||||||
db_session.add(PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="10.0.0.5", mac="bc:24:11:aa:bb:cc",
|
|
||||||
suggested_type="generic", status="pending",
|
|
||||||
discovery_source="arp", discovery_sources=["arp"],
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
# Proxmox import of the same box (stopped VM → no IP) but same NIC MAC in a
|
|
||||||
# different casing. Must merge, not duplicate.
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
|
|
||||||
|
|
||||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
row = rows[0]
|
|
||||||
assert row.ieee_address == "pve-pve1-101" # adopted proxmox identity
|
|
||||||
assert row.suggested_type == "vm" # kept proxmox type
|
|
||||||
assert set(row.discovery_sources) == {"arp", "proxmox"} # shows in both filters
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -> None:
|
|
||||||
# Legacy inventory row from an old IP scan, before discovery_source(s) were
|
|
||||||
# recorded: scalar NULL, sources empty — but it has an IP + MAC.
|
|
||||||
db_session.add(PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
|
||||||
suggested_type="lxc", status="pending",
|
|
||||||
discovery_source=None, discovery_sources=[],
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(
|
|
||||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
# The IP tag must survive the proxmox merge even with no recorded origin.
|
|
||||||
assert set(rows[0].discovery_sources) == {"arp", "proxmox"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -> None:
|
|
||||||
# The immich case: an on-canvas node from an old scan, with a legacy
|
|
||||||
# inventory row (NULL source). Merge must keep the IP tag on the row.
|
|
||||||
node = Node(
|
|
||||||
id=str(uuid.uuid4()), type="lxc", label="immich",
|
|
||||||
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
|
||||||
status="online", pos_x=0, pos_y=0,
|
|
||||||
)
|
|
||||||
inv = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
|
|
||||||
suggested_type="lxc", status="approved",
|
|
||||||
discovery_source=None, discovery_sources=[],
|
|
||||||
)
|
|
||||||
db_session.add_all([node, inv])
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(
|
|
||||||
db_session, [_guest_node(108, "192.168.1.108", mac="BC:24:11:6C:96:52")], []
|
|
||||||
)
|
|
||||||
|
|
||||||
inv_row = (await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.mac == "bc:24:11:6c:96:52")
|
|
||||||
)).scalar_one()
|
|
||||||
assert set(inv_row.discovery_sources) == {"arp", "proxmox"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) -> None:
|
|
||||||
# A guest first seen via Proxmox (agent IP, pve ieee) must NOT gain a spurious
|
|
||||||
# IP tag on re-sync — it was never IP-scanned.
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], []) # re-sync
|
|
||||||
row = (await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101")
|
|
||||||
)).scalar_one()
|
|
||||||
assert set(row.discovery_sources) == {"proxmox"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
|
|
||||||
# A scanned canvas node with a MAC but no IP recorded for the guest.
|
|
||||||
scanned = Node(
|
|
||||||
id=str(uuid.uuid4()), type="generic", label="box",
|
|
||||||
mac="bc:24:11:aa:bb:cc", status="online", pos_x=0, pos_y=0,
|
|
||||||
)
|
|
||||||
db_session.add(scanned)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, None, mac="BC:24:11:AA:BB:CC")], [])
|
|
||||||
|
|
||||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
|
||||||
assert len(nodes) == 1 # no duplicate node
|
|
||||||
merged = nodes[0]
|
|
||||||
assert merged.ieee_address == "pve-pve1-101"
|
|
||||||
assert merged.mac == "bc:24:11:aa:bb:cc"
|
|
||||||
assert merged.cpu_count == 2 # specs backfilled
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_resync_updates_in_place(db_session) -> None:
|
|
||||||
nodes = [_guest_node(101, "10.0.0.5")]
|
|
||||||
await _persist_pending_import(db_session, nodes, [])
|
|
||||||
# Second sync: same device, new IP. Should update, not duplicate.
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.9")], [])
|
|
||||||
rows = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert rows[0].ip == "10.0.0.9"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
|
||||||
db_session.add(PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ieee_address="pve-pve1-101", ip="10.0.0.5",
|
|
||||||
suggested_type="vm", status="hidden", discovery_source="proxmox",
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
|
||||||
row = (await db_session.execute(select(PendingDevice).where(PendingDevice.ieee_address == "pve-pve1-101"))).scalar_one()
|
|
||||||
assert row.status == "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pending_endpoint_tolerates_legacy_null_properties(client: AsyncClient, headers: dict, db_session) -> None:
|
|
||||||
# Legacy row: properties column NULL (added by migration on older DBs).
|
|
||||||
dev = PendingDevice(
|
|
||||||
id=str(uuid.uuid4()), ip="192.168.1.9", suggested_type="server",
|
|
||||||
status="pending", discovery_source="arp",
|
|
||||||
)
|
|
||||||
dev.properties = None
|
|
||||||
db_session.add(dev)
|
|
||||||
await db_session.commit()
|
|
||||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()[0]["properties"] == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- cluster links ---------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_records_cluster_links_between_hosts(db_session) -> None:
|
|
||||||
# Two hosts + a guest → one host↔host cluster link, one host→guest link.
|
|
||||||
nodes = [
|
|
||||||
{**_host_node(), "id": "pve-node-a", "ieee_address": "pve-node-a", "hostname": "a", "label": "a"},
|
|
||||||
{**_host_node(), "id": "pve-node-b", "ieee_address": "pve-node-b", "hostname": "b", "label": "b"},
|
|
||||||
_guest_node(101, "10.0.0.5"),
|
|
||||||
]
|
|
||||||
edges = [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
|
||||||
await _persist_pending_import(db_session, nodes, edges)
|
|
||||||
|
|
||||||
links = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
|
||||||
cluster = [ln for ln in links if ln.discovery_source == "proxmox_cluster"]
|
|
||||||
assert len(cluster) == 1
|
|
||||||
assert (cluster[0].source_ieee, cluster[0].target_ieee) == ("pve-node-a", "pve-node-b")
|
|
||||||
# Membership helper sees both endpoints.
|
|
||||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-a") is True
|
|
||||||
assert await _is_proxmox_cluster_member(db_session, "pve-node-b") is True
|
|
||||||
assert await _is_proxmox_cluster_member(db_session, "pve-pve1-101") is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_single_host_records_no_cluster_link(db_session) -> None:
|
|
||||||
await _persist_pending_import(db_session, [_host_node()], [])
|
|
||||||
links = (await db_session.execute(
|
|
||||||
select(PendingDeviceLink).where(PendingDeviceLink.discovery_source == "proxmox_cluster")
|
|
||||||
)).scalars().all()
|
|
||||||
assert links == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
|
|
||||||
# Two host nodes already on a canvas + a pending cluster link between them.
|
|
||||||
design = Design(id=str(uuid.uuid4()), name="d")
|
|
||||||
db_session.add(design)
|
|
||||||
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
|
|
||||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
|
||||||
left_handles=1, right_handles=1)
|
|
||||||
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
|
|
||||||
status="online", pos_x=0, pos_y=0, design_id=design.id,
|
|
||||||
left_handles=1, right_handles=1)
|
|
||||||
db_session.add_all([a, b])
|
|
||||||
db_session.add(PendingDeviceLink(
|
|
||||||
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
|
|
||||||
discovery_source="proxmox_cluster",
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _resolve_pending_links_for_ieee(db_session, "pve-node-a", design.id)
|
|
||||||
|
|
||||||
edge = (await db_session.execute(select(Edge))).scalars().one()
|
|
||||||
assert edge.type == "cluster"
|
|
||||||
assert edge.source_handle == "right"
|
|
||||||
# Bare side name (canonical) so React Flow resolves it to the left side;
|
|
||||||
# a '-t' target would fall back to the top handle.
|
|
||||||
assert edge.target_handle == "left"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_link_survives_and_resolves_onto_second_design(db_session) -> None:
|
|
||||||
# Regression: approving the same mesh devices onto a second canvas must also
|
|
||||||
# get their edges. The link row is topology, not one-shot — resolving it on
|
|
||||||
# design A must not consume it, so design B resolves too.
|
|
||||||
da = Design(id=str(uuid.uuid4()), name="a")
|
|
||||||
db_ = Design(id=str(uuid.uuid4()), name="b")
|
|
||||||
db_session.add_all([da, db_])
|
|
||||||
# Same two devices placed on BOTH designs (one Node per canvas).
|
|
||||||
for d in (da, db_):
|
|
||||||
db_session.add_all([
|
|
||||||
Node(id=str(uuid.uuid4()), type="iot", label="x", ieee_address="0xAAAA",
|
|
||||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
|
||||||
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
|
|
||||||
status="online", pos_x=0, pos_y=0, design_id=d.id),
|
|
||||||
])
|
|
||||||
db_session.add(PendingDeviceLink(
|
|
||||||
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
first = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", da.id)
|
|
||||||
second = await _resolve_pending_links_for_ieee(db_session, "0xAAAA", db_.id)
|
|
||||||
|
|
||||||
assert len(first) == 1
|
|
||||||
assert len(second) == 1 # would be 0 if the link were consumed on design A
|
|
||||||
# One iot edge per design, each between that design's own nodes.
|
|
||||||
edges = (await db_session.execute(select(Edge))).scalars().all()
|
|
||||||
assert len(edges) == 2
|
|
||||||
assert {e.design_id for e in edges} == {da.id, db_.id}
|
|
||||||
assert all(e.type == "iot" for e in edges)
|
|
||||||
# The link row is still present for any further design.
|
|
||||||
assert (await db_session.execute(select(PendingDeviceLink))).scalars().one()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_never_deletes(db_session) -> None:
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
|
|
||||||
# A later sync that no longer includes vm101 must not remove it.
|
|
||||||
await _persist_pending_import(db_session, [_guest_node(202, "10.0.0.6")], [])
|
|
||||||
rows = (await db_session.execute(select(PendingDevice))).scalars().all()
|
|
||||||
ieees = {r.ieee_address for r in rows}
|
|
||||||
assert "pve-pve1-101" in ieees and "pve-pve1-202" in ieees
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"""Unit tests for the Proxmox VE import service (parsing, props, sanitizer)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services import proxmox_service as svc
|
|
||||||
|
|
||||||
|
|
||||||
def test_gb_conversion() -> None:
|
|
||||||
assert svc._gb(1024 ** 3) == 1.0
|
|
||||||
assert svc._gb(2 * 1024 ** 3) == 2.0
|
|
||||||
assert svc._gb(0) is None
|
|
||||||
assert svc._gb(None) is None
|
|
||||||
assert svc._gb("nope") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_qemu_ip_skips_loopback() -> None:
|
|
||||||
payload = {
|
|
||||||
"result": [
|
|
||||||
{"name": "lo", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.1"}]},
|
|
||||||
{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "192.168.1.20"}]},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
assert svc._extract_qemu_ip(payload) == "192.168.1.20"
|
|
||||||
assert svc._extract_qemu_ip(None) is None
|
|
||||||
assert svc._extract_qemu_ip({"result": []}) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_lxc_ip_parses_net0_static() -> None:
|
|
||||||
cfg = {"net0": "name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.1"}
|
|
||||||
assert svc._extract_lxc_ip(cfg) == "192.168.1.30"
|
|
||||||
# DHCP → no static IP
|
|
||||||
assert svc._extract_lxc_ip({"net0": "name=eth0,bridge=vmbr0,ip=dhcp"}) is None
|
|
||||||
assert svc._extract_lxc_ip(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_extract_net_mac_parses_qemu_and_lxc_forms() -> None:
|
|
||||||
# qemu: "virtio=<MAC>,bridge=.."
|
|
||||||
assert svc._extract_net_mac({"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0"}) == "bc:24:11:aa:bb:cc"
|
|
||||||
# lxc: "..,hwaddr=<MAC>,.."
|
|
||||||
assert (
|
|
||||||
svc._extract_net_mac({"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:11:22:33,ip=dhcp"})
|
|
||||||
== "bc:24:11:11:22:33"
|
|
||||||
)
|
|
||||||
# No MAC / missing net0 / None → None
|
|
||||||
assert svc._extract_net_mac({"net0": "name=eth0,ip=dhcp"}) is None
|
|
||||||
assert svc._extract_net_mac({}) is None
|
|
||||||
assert svc._extract_net_mac(None) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_inventory_captures_guest_mac_from_config() -> None:
|
|
||||||
"""Guests carry a normalized NIC MAC read agent-free from their config."""
|
|
||||||
async def fake_get_json(client, path: str):
|
|
||||||
if path == "/nodes":
|
|
||||||
return [{"node": "pve1", "status": "online", "maxcpu": 8}]
|
|
||||||
if path == "/nodes/pve1/qemu":
|
|
||||||
return [{"vmid": 101, "name": "web", "status": "stopped"}] # stopped: no agent IP
|
|
||||||
if path == "/nodes/pve1/lxc":
|
|
||||||
return [{"vmid": 200, "name": "db", "status": "running"}]
|
|
||||||
if path == "/nodes/pve1/qemu/101/config":
|
|
||||||
return {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0"}
|
|
||||||
if path == "/nodes/pve1/lxc/200/config":
|
|
||||||
return {"net0": "name=eth0,bridge=vmbr0,hwaddr=11:22:33:44:55:66,ip=10.0.0.6/24"}
|
|
||||||
return None
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
|
||||||
nodes, _ = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
|
||||||
|
|
||||||
vm = next(n for n in nodes if n["type"] == "vm")
|
|
||||||
assert vm["mac"] == "aa:bb:cc:dd:ee:ff" # captured even though VM is stopped (no IP)
|
|
||||||
assert vm["ip"] is None
|
|
||||||
ct = next(n for n in nodes if n["type"] == "lxc")
|
|
||||||
assert ct["mac"] == "11:22:33:44:55:66"
|
|
||||||
assert ct["ip"] == "10.0.0.6"
|
|
||||||
|
|
||||||
|
|
||||||
def test_host_and_guest_node_mapping() -> None:
|
|
||||||
host = svc._host_node({"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3, "maxdisk": 500 * 1024 ** 3})
|
|
||||||
assert host["type"] == "proxmox"
|
|
||||||
assert host["ieee_address"] == "pve-node-pve1"
|
|
||||||
assert host["cpu_count"] == 8
|
|
||||||
assert host["ram_gb"] == 16.0
|
|
||||||
assert host["status"] == "online"
|
|
||||||
assert host["parent_ieee"] is None
|
|
||||||
|
|
||||||
vm = svc._guest_node({"vmid": 101, "name": "web", "status": "running", "maxcpu": 2, "maxmem": 2 * 1024 ** 3, "maxdisk": 32 * 1024 ** 3}, "pve1", "qemu", "10.0.0.5")
|
|
||||||
assert vm["type"] == "vm"
|
|
||||||
assert vm["ieee_address"] == "pve-pve1-101"
|
|
||||||
assert vm["ip"] == "10.0.0.5"
|
|
||||||
assert vm["status"] == "online"
|
|
||||||
assert vm["parent_ieee"] == "pve-node-pve1"
|
|
||||||
|
|
||||||
ct = svc._guest_node({"vmid": 200, "name": "db", "status": "stopped"}, "pve1", "lxc", None)
|
|
||||||
assert ct["type"] == "lxc"
|
|
||||||
assert ct["status"] == "offline"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_properties_includes_specs() -> None:
|
|
||||||
node = {"vmid": 101, "model": "QEMU", "cpu_count": 4, "ram_gb": 8.0, "disk_gb": 40.0}
|
|
||||||
props = svc.build_proxmox_properties(node)
|
|
||||||
keys = {p["key"] for p in props}
|
|
||||||
assert {"VMID", "CPU Cores", "RAM", "Disk", "Source"} <= keys
|
|
||||||
assert all(p["visible"] is False for p in props)
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_inventory_builds_host_guest_edges() -> None:
|
|
||||||
hosts = [{"node": "pve1", "status": "online", "maxcpu": 4}]
|
|
||||||
guests = {"pve1": [svc._guest_node({"vmid": 101, "name": "web", "status": "running"}, "pve1", "qemu", None)]}
|
|
||||||
nodes, edges = svc._parse_inventory(hosts, guests)
|
|
||||||
assert len(nodes) == 2
|
|
||||||
assert edges == [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_cluster_links_chains_hosts() -> None:
|
|
||||||
nodes = [
|
|
||||||
svc._host_node({"node": "pve-a", "status": "online"}),
|
|
||||||
svc._guest_node({"vmid": 101, "status": "running"}, "pve-a", "qemu", None),
|
|
||||||
svc._host_node({"node": "pve-b", "status": "online"}),
|
|
||||||
svc._host_node({"node": "pve-c", "status": "online"}),
|
|
||||||
]
|
|
||||||
pairs = svc.build_proxmox_cluster_links(nodes)
|
|
||||||
assert pairs == [("pve-node-pve-a", "pve-node-pve-b"), ("pve-node-pve-b", "pve-node-pve-c")]
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_cluster_links_single_host_is_not_a_cluster() -> None:
|
|
||||||
nodes = [svc._host_node({"node": "pve-a", "status": "online"})]
|
|
||||||
assert svc.build_proxmox_cluster_links(nodes) == []
|
|
||||||
# Guests alone never form a cluster.
|
|
||||||
guest = svc._guest_node({"vmid": 1, "status": "running"}, "pve-a", "qemu", None)
|
|
||||||
assert svc.build_proxmox_cluster_links([guest]) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_error_hides_credentials() -> None:
|
|
||||||
exc = httpx.HTTPStatusError(
|
|
||||||
"boom", request=httpx.Request("GET", "https://h/api2/json"),
|
|
||||||
response=httpx.Response(401),
|
|
||||||
)
|
|
||||||
msg = svc._sanitize_proxmox_error(exc)
|
|
||||||
assert "token" not in msg.lower() or "check the api token" in msg.lower()
|
|
||||||
assert "Authentication failed" in msg
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_inventory_happy_path() -> None:
|
|
||||||
async def fake_get_json(client, path: str):
|
|
||||||
if path == "/nodes":
|
|
||||||
return [{"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3}]
|
|
||||||
if path == "/nodes/pve1/qemu":
|
|
||||||
return [{"vmid": 101, "name": "web", "status": "running", "maxmem": 2 * 1024 ** 3}]
|
|
||||||
if path == "/nodes/pve1/lxc":
|
|
||||||
return [{"vmid": 200, "name": "db", "status": "stopped"}]
|
|
||||||
if path.endswith("/agent/network-get-interfaces"):
|
|
||||||
return {"result": [{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.0.0.5"}]}]}
|
|
||||||
if path.endswith("/config"):
|
|
||||||
return {"net0": "name=eth0,ip=10.0.0.6/24"}
|
|
||||||
return None
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
|
||||||
nodes, edges = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
|
||||||
|
|
||||||
by_type = {n["type"] for n in nodes}
|
|
||||||
assert by_type == {"proxmox", "vm", "lxc"}
|
|
||||||
vm = next(n for n in nodes if n["type"] == "vm")
|
|
||||||
assert vm["ip"] == "10.0.0.5"
|
|
||||||
ct = next(n for n in nodes if n["type"] == "lxc")
|
|
||||||
assert ct["ip"] == "10.0.0.6"
|
|
||||||
assert len(edges) == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_returns_message() -> None:
|
|
||||||
async def fake_get_json(client, path: str):
|
|
||||||
if path == "/access/permissions":
|
|
||||||
return {"/": {"VM.Audit": 1}} # token has an ACL
|
|
||||||
return {"version": "8.2.2"}
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
|
||||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
|
||||||
assert ok is True
|
|
||||||
assert "8.2.2" in msg
|
|
||||||
assert "warning" not in msg.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_warns_when_token_has_no_permissions() -> None:
|
|
||||||
async def fake_get_json(client, path: str):
|
|
||||||
if path == "/access/permissions":
|
|
||||||
return {} # privilege-separated token with no effective ACL
|
|
||||||
return {"version": "8.4.19"}
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
|
||||||
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
|
||||||
# Auth still succeeds; the message flags the permission gap.
|
|
||||||
assert ok is True
|
|
||||||
assert "8.4.19" in msg
|
|
||||||
assert "PVEAuditor" in msg
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_token_has_permissions_treats_empty_as_no_perms() -> None:
|
|
||||||
async def fake_get_json(client, path: str):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
|
||||||
assert await svc._token_has_permissions(object()) is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_token_has_permissions_assumes_ok_on_error() -> None:
|
|
||||||
async def boom(client, path: str):
|
|
||||||
raise httpx.ConnectError("nope")
|
|
||||||
|
|
||||||
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=boom)):
|
|
||||||
# Never block a valid import on a permissions-probe failure.
|
|
||||||
assert await svc._token_has_permissions(object()) is True
|
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db.models import PendingDevice, ScanRun
|
||||||
|
from app.services.scanner import run_scan
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def headers(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||||
|
token = res.json()["access_token"]
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def pending_device(db_session):
|
||||||
|
import uuid
|
||||||
|
device = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.100",
|
||||||
|
mac="aa:bb:cc:dd:ee:ff",
|
||||||
|
hostname="my-server",
|
||||||
|
os="Linux",
|
||||||
|
services=[{"port": 22, "name": "ssh"}],
|
||||||
|
suggested_type="server",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(device)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(device)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
# --- Trigger scan ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
||||||
|
res = await client.post("/api/v1/scan/trigger")
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
||||||
|
with (
|
||||||
|
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
||||||
|
patch("app.api.routes.scan.settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
||||||
|
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["status"] == "running"
|
||||||
|
assert data["ranges"] == ["192.168.1.0/24"]
|
||||||
|
assert "id" in data
|
||||||
|
|
||||||
|
|
||||||
|
# --- Pending devices ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_pending_empty(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["ip"] == "192.168.1.100"
|
||||||
|
assert data[0]["hostname"] == "my-server"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Approve device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
||||||
|
node_payload = {
|
||||||
|
"label": "My Server",
|
||||||
|
"type": "server",
|
||||||
|
"ip": "192.168.1.100",
|
||||||
|
"hostname": "my-server",
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
}
|
||||||
|
res = await client.post(
|
||||||
|
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||||
|
json=node_payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.json()
|
||||||
|
assert data["approved"] is True
|
||||||
|
assert "node_id" in data
|
||||||
|
|
||||||
|
# Device should no longer appear in pending list
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||||
|
node_payload = {
|
||||||
|
"label": "Ghost",
|
||||||
|
"type": "generic",
|
||||||
|
"ip": "10.0.0.1",
|
||||||
|
"status": "unknown",
|
||||||
|
"services": [],
|
||||||
|
}
|
||||||
|
res = await client.post(
|
||||||
|
"/api/v1/scan/pending/nonexistent-id/approve",
|
||||||
|
json=node_payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["approved"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- Hide device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["hidden"] is True
|
||||||
|
|
||||||
|
# Should no longer appear in pending
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert pending_res.json() == []
|
||||||
|
|
||||||
|
# Should appear in hidden
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert len(hidden_res.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Ignore device ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||||
|
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["ignored"] is True
|
||||||
|
|
||||||
|
# Device should be gone from both pending and hidden
|
||||||
|
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||||
|
assert pending_res.json() == []
|
||||||
|
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||||
|
assert hidden_res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- Scan runs ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_runs_empty(client: AsyncClient, headers):
|
||||||
|
res = await client.get("/api/v1/scan/runs", headers=headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- run_scan: re-scan updates existing pending devices ---
|
||||||
|
|
||||||
|
MOCK_HOST = {
|
||||||
|
"ip": "192.168.1.50",
|
||||||
|
"mac": "aa:bb:cc:dd:ee:ff",
|
||||||
|
"hostname": "myhost.lan",
|
||||||
|
"os": "Linux",
|
||||||
|
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
|
)
|
||||||
|
device = result.scalar_one_or_none()
|
||||||
|
assert device is not None
|
||||||
|
assert device.hostname == "myhost.lan"
|
||||||
|
assert any(s["port"] == 8096 for s in device.services)
|
||||||
|
assert device.suggested_type == "server"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
||||||
|
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
||||||
|
# Pre-existing pending device with no services
|
||||||
|
existing = PendingDevice(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
ip="192.168.1.50",
|
||||||
|
mac=None,
|
||||||
|
hostname=None,
|
||||||
|
os=None,
|
||||||
|
services=[],
|
||||||
|
suggested_type="generic",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(existing)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
run_id = str(uuid.uuid4())
|
||||||
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
||||||
|
db_session.add(run)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
||||||
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
||||||
|
):
|
||||||
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
||||||
|
|
||||||
|
# Should still be only one device
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
||||||
|
)
|
||||||
|
devices = list(result.scalars().all())
|
||||||
|
assert len(devices) == 1
|
||||||
|
device = devices[0]
|
||||||
|
# Services and hostname should be updated
|
||||||
|
assert device.hostname == "myhost.lan"
|
||||||
|
assert any(s["port"] == 8096 for s in device.services)
|
||||||
@@ -1,976 +0,0 @@
|
|||||||
"""Tests for scanner: two-phase nmap, mDNS discovery, run_scan integration."""
|
|
||||||
import uuid
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy import select as sa_select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
from app.db.database import Base
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_run_id() -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def mem_db():
|
|
||||||
engine = create_async_engine(
|
|
||||||
"sqlite+aiosqlite:///:memory:",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
|
||||||
yield factory
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_scan_run(run_id: str) -> ScanRun:
|
|
||||||
return ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _ping_sweep
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_sweep_returns_alive_hosts():
|
|
||||||
from app.services.scanner import _ping_sweep
|
|
||||||
|
|
||||||
async def fake_ping(ip: str) -> str | None:
|
|
||||||
return ip if ip in {"192.168.1.1", "192.168.1.2"} else None
|
|
||||||
|
|
||||||
with patch("app.services.scanner._ping_sweep", wraps=None):
|
|
||||||
pass # just ensure import is fine
|
|
||||||
|
|
||||||
# Patch asyncio.create_subprocess_exec to simulate ping responses
|
|
||||||
responding = {"192.168.1.1", "192.168.1.2"}
|
|
||||||
|
|
||||||
async def mock_subprocess(*args, **kwargs):
|
|
||||||
ip = args[-1]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0 if ip in responding else 1
|
|
||||||
proc.wait = AsyncMock(return_value=proc.returncode)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
|
||||||
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = await _ping_sweep("192.168.1.0/30") # .1 .2 only in /30
|
|
||||||
|
|
||||||
assert "192.168.1.1" in result
|
|
||||||
assert "192.168.1.2" in result
|
|
||||||
for host in result.values():
|
|
||||||
assert host["open_ports"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_sweep_excludes_non_responding():
|
|
||||||
from app.services.scanner import _ping_sweep
|
|
||||||
|
|
||||||
async def mock_subprocess(*args, **kwargs):
|
|
||||||
ip = args[-1]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
|
||||||
proc.wait = AsyncMock(return_value=proc.returncode)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
|
||||||
patch("app.services.scanner._arp_table_hosts", return_value={}), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = await _ping_sweep("192.168.1.0/30")
|
|
||||||
|
|
||||||
assert "192.168.1.1" in result
|
|
||||||
assert "192.168.1.2" not in result
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_sweep_supplements_with_arp_cache():
|
|
||||||
"""Devices that block ICMP but appear in ARP cache should still be discovered."""
|
|
||||||
from app.services.scanner import _ping_sweep
|
|
||||||
|
|
||||||
async def mock_subprocess(*args, **kwargs):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 1 # all pings fail
|
|
||||||
proc.wait = AsyncMock(return_value=1)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
arp_extra = {
|
|
||||||
"192.168.1.10": {"ip": "192.168.1.10", "mac": "aa:bb:cc:dd:ee:10", "hostname": None, "os": None, "open_ports": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
|
||||||
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = await _ping_sweep("192.168.1.0/24")
|
|
||||||
|
|
||||||
assert "192.168.1.10" in result
|
|
||||||
assert result["192.168.1.10"]["mac"] == "aa:bb:cc:dd:ee:10"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_sweep_enriches_mac_from_arp_cache():
|
|
||||||
"""Ping-alive hosts with no ARP entry get their MAC from the ARP cache."""
|
|
||||||
from app.services.scanner import _ping_sweep
|
|
||||||
|
|
||||||
async def mock_subprocess(*args, **kwargs):
|
|
||||||
ip = args[-1]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0 if ip == "192.168.1.1" else 1
|
|
||||||
proc.wait = AsyncMock(return_value=proc.returncode)
|
|
||||||
return proc
|
|
||||||
|
|
||||||
arp_extra = {
|
|
||||||
"192.168.1.1": {"ip": "192.168.1.1", "mac": "de:ad:be:ef:00:01", "hostname": None, "os": None, "open_ports": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec", side_effect=mock_subprocess), \
|
|
||||||
patch("app.services.scanner._arp_table_hosts", return_value=arp_extra), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = await _ping_sweep("192.168.1.0/30")
|
|
||||||
|
|
||||||
assert result["192.168.1.1"]["mac"] == "de:ad:be:ef:00:01"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _arp_table_hosts
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_arp_table_hosts_parses_proc_net_arp():
|
|
||||||
import io # noqa: PLC0415
|
|
||||||
|
|
||||||
from app.services.scanner import _arp_table_hosts
|
|
||||||
|
|
||||||
arp_content = (
|
|
||||||
"IP address HW type Flags HW address Mask Device\n"
|
|
||||||
"192.168.1.1 0x1 0x2 aa:bb:cc:dd:ee:01 * eth0\n"
|
|
||||||
"192.168.1.50 0x1 0x2 aa:bb:cc:dd:ee:02 * eth0\n"
|
|
||||||
"10.0.0.1 0x1 0x2 aa:bb:cc:dd:ee:03 * eth0\n" # outside subnet
|
|
||||||
"192.168.1.99 0x1 0x2 00:00:00:00:00:00 * eth0\n" # incomplete
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_file = MagicMock()
|
|
||||||
mock_file.__enter__ = MagicMock(return_value=io.StringIO(arp_content))
|
|
||||||
mock_file.__exit__ = MagicMock(return_value=False)
|
|
||||||
|
|
||||||
with patch("builtins.open", return_value=mock_file), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = _arp_table_hosts("192.168.1.0/24")
|
|
||||||
|
|
||||||
assert "192.168.1.1" in result
|
|
||||||
assert "192.168.1.50" in result
|
|
||||||
assert "10.0.0.1" not in result # outside target subnet
|
|
||||||
assert "192.168.1.99" not in result # zero MAC skipped
|
|
||||||
|
|
||||||
|
|
||||||
def test_arp_table_hosts_parses_macos_arp_output():
|
|
||||||
from app.services.scanner import _arp_table_hosts
|
|
||||||
|
|
||||||
arp_output = (
|
|
||||||
"router.lan (192.168.1.1) at aa:bb:cc:dd:ee:01 on en0 ifscope [ethernet]\n"
|
|
||||||
"device.lan (192.168.1.20) at aa:bb:cc:dd:ee:02 on en0 ifscope [ethernet]\n"
|
|
||||||
"? (192.168.1.99) at (incomplete) on en0 ifscope [ethernet]\n"
|
|
||||||
"? (10.0.0.1) at aa:bb:cc:dd:ee:04 on en0 ifscope [ethernet]\n" # outside subnet
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_result = MagicMock()
|
|
||||||
mock_result.stdout = arp_output
|
|
||||||
|
|
||||||
with patch("builtins.open", side_effect=FileNotFoundError), \
|
|
||||||
patch("subprocess.run", return_value=mock_result), \
|
|
||||||
patch("app.services.scanner._resolve_hostname", return_value=None):
|
|
||||||
result = _arp_table_hosts("192.168.1.0/24")
|
|
||||||
|
|
||||||
assert "192.168.1.1" in result
|
|
||||||
assert "192.168.1.20" in result
|
|
||||||
assert "192.168.1.99" not in result # incomplete MAC
|
|
||||||
assert "10.0.0.1" not in result # outside subnet
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _nmap_scan_single (Phase 2 per-IP worker)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_nmap_scan_single_detects_open_ports():
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
|
|
||||||
# Build a realistic host entry: protocols → ports → port info
|
|
||||||
port_info = {80: {"state": "open", "product": "nginx", "version": "1.24"}}
|
|
||||||
mock_host = MagicMock()
|
|
||||||
mock_host.all_protocols.return_value = ["tcp"]
|
|
||||||
mock_host.__getitem__ = MagicMock(return_value=port_info)
|
|
||||||
mock_host.get.return_value = {}
|
|
||||||
|
|
||||||
mock_nm = MagicMock()
|
|
||||||
mock_nm.all_hosts.return_value = ["192.168.1.10"]
|
|
||||||
mock_nm.__getitem__ = MagicMock(return_value=mock_host)
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm), \
|
|
||||||
patch("app.services.scanner._extract_os", return_value=None):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert len(result["open_ports"]) == 1
|
|
||||||
assert result["open_ports"][0]["port"] == 80
|
|
||||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_returns_host_unchanged_on_error():
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.20", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
mock_nm = MagicMock()
|
|
||||||
mock_nm.scan.side_effect = Exception("nmap error")
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert result["ip"] == "192.168.1.20"
|
|
||||||
assert result["open_ports"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_returns_host_unchanged_when_no_results():
|
|
||||||
"""Host confirmed alive in Phase 1 but all ports filtered — keep it with empty ports."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.30", "hostname": "shelly1.lan", "mac": "34:94:54:aa:bb:cc", "os": None, "open_ports": []}
|
|
||||||
mock_nm = MagicMock()
|
|
||||||
mock_nm.all_hosts.return_value = [] # no results
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", return_value=mock_nm):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert result["ip"] == "192.168.1.30"
|
|
||||||
assert result["open_ports"] == []
|
|
||||||
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _nmap_scan_single — two-pass discovery/version split (issue #277)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _fake_scanner(ip, port_info, mac=None):
|
|
||||||
"""Mock nmap.PortScanner whose results contain `ip` with tcp `port_info`."""
|
|
||||||
host = MagicMock()
|
|
||||||
host.all_protocols.return_value = ["tcp"]
|
|
||||||
host.__getitem__ = MagicMock(return_value=port_info)
|
|
||||||
host.get.return_value = {"mac": mac} if mac else {}
|
|
||||||
nm = MagicMock()
|
|
||||||
nm.all_hosts.return_value = [ip]
|
|
||||||
nm.__getitem__ = MagicMock(return_value=host)
|
|
||||||
return nm
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_scanner():
|
|
||||||
nm = MagicMock()
|
|
||||||
nm.all_hosts.return_value = []
|
|
||||||
return nm
|
|
||||||
|
|
||||||
|
|
||||||
def _failing_scanner(exc=Exception("host timeout")):
|
|
||||||
nm = MagicMock()
|
|
||||||
nm.scan.side_effect = exc
|
|
||||||
return nm
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_two_pass_merges_banners():
|
|
||||||
"""Pass A discovers ports; Pass B enriches them with -sV banners."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.10", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
disc = _fake_scanner("192.168.1.10", {22: {"state": "open"}, 8006: {"state": "open"}})
|
|
||||||
ver = _fake_scanner("192.168.1.10", {
|
|
||||||
22: {"state": "open", "product": "OpenSSH", "version": "9.0"},
|
|
||||||
8006: {"state": "open", "product": "", "version": ""},
|
|
||||||
})
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
|
||||||
patch("app.services.scanner.os.geteuid", return_value=0), \
|
|
||||||
patch("app.services.scanner._extract_os", return_value=None):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
banners = {p["port"]: p["banner"] for p in result["open_ports"]}
|
|
||||||
assert banners == {22: "OpenSSH 9.0", 8006: ""}
|
|
||||||
|
|
||||||
# Pass A: discovery only, no -sV / host-timeout. Pass B: -sV, bounded.
|
|
||||||
disc_args = disc.scan.call_args.kwargs["arguments"]
|
|
||||||
ver_args = ver.scan.call_args.kwargs["arguments"]
|
|
||||||
assert "-sV" not in disc_args and "--host-timeout" not in disc_args
|
|
||||||
assert "-sV" in ver_args and "--host-timeout 60s" in ver_args
|
|
||||||
assert ver_args.endswith("-p 22,8006") # version pass scoped to found ports
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_keeps_ports_when_version_pass_fails():
|
|
||||||
"""Regression #277: a stalling version pass must not drop discovered ports."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.100.3", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
disc = _fake_scanner("192.168.100.3", {22: {"state": "open"}, 8006: {"state": "open"}})
|
|
||||||
ver = _failing_scanner() # -sV blows past --host-timeout on the TLS port
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
|
||||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
ports = {p["port"] for p in result["open_ports"]}
|
|
||||||
assert ports == {22, 8006} # both survive despite the version failure
|
|
||||||
assert all(p["banner"] == "" for p in result["open_ports"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_keeps_ports_when_version_pass_empty():
|
|
||||||
"""Version pass returns no results for the host — keep the discovered ports."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.11", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
disc = _fake_scanner("192.168.1.11", {443: {"state": "open"}})
|
|
||||||
ver = _empty_scanner()
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
|
||||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert [p["port"] for p in result["open_ports"]] == [443]
|
|
||||||
assert result["open_ports"][0]["banner"] == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_no_open_ports_skips_version_pass():
|
|
||||||
"""Host reachable but nothing open — no version pass, empty ports."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.12", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
disc = _fake_scanner("192.168.1.12", {80: {"state": "filtered"}})
|
|
||||||
|
|
||||||
# Only one PortScanner instance may be created (Pass B must be skipped);
|
|
||||||
# a second would raise StopIteration from side_effect.
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc]), \
|
|
||||||
patch("app.services.scanner.os.geteuid", return_value=0):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert result["open_ports"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_nmap_scan_single_non_root_uses_connect_scan():
|
|
||||||
"""Without root, both passes use -sT (connect) instead of -sS (SYN)."""
|
|
||||||
from app.services.scanner import _nmap_scan_single
|
|
||||||
|
|
||||||
host = {"ip": "192.168.1.13", "hostname": None, "mac": None, "os": None, "open_ports": []}
|
|
||||||
disc = _fake_scanner("192.168.1.13", {80: {"state": "open"}})
|
|
||||||
ver = _fake_scanner("192.168.1.13", {80: {"state": "open", "product": "nginx", "version": "1.24"}})
|
|
||||||
|
|
||||||
with patch("app.services.scanner.nmap.PortScanner", side_effect=[disc, ver]), \
|
|
||||||
patch("app.services.scanner.os.geteuid", return_value=1000), \
|
|
||||||
patch("app.services.scanner._extract_os", return_value=None):
|
|
||||||
result = _nmap_scan_single(host)
|
|
||||||
|
|
||||||
assert disc.scan.call_args.kwargs["arguments"].startswith("-sT")
|
|
||||||
assert ver.scan.call_args.kwargs["arguments"].startswith("-sT")
|
|
||||||
assert result["open_ports"][0]["banner"] == "nginx 1.24"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _nmap_scan
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_scan_uses_mock_when_nmap_unavailable():
|
|
||||||
from app.services.scanner import _nmap_scan
|
|
||||||
|
|
||||||
with patch("app.services.scanner._NMAP_AVAILABLE", False):
|
|
||||||
result = await _nmap_scan("192.168.1.0/24")
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["ip"] == "192.168.1.99"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_scan_raises_on_sweep_error():
|
|
||||||
from app.services.scanner import _nmap_scan
|
|
||||||
|
|
||||||
with patch("app.services.scanner._ping_sweep", side_effect=Exception("ping sweep failed")), \
|
|
||||||
pytest.raises(RuntimeError, match="ping sweep failed"):
|
|
||||||
await _nmap_scan("192.168.1.0/24")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Cancellation responsiveness (issue #218)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_scan_cancelled_before_start_skips_phases():
|
|
||||||
"""A run already cancelled returns immediately without touching the network."""
|
|
||||||
from app.services.scanner import _cancelled_runs, _nmap_scan, request_cancel
|
|
||||||
|
|
||||||
run_id = "cancel-before-start"
|
|
||||||
request_cancel(run_id)
|
|
||||||
try:
|
|
||||||
with patch("app.services.scanner._ping_sweep", new_callable=AsyncMock) as mock_sweep, \
|
|
||||||
patch("app.services.scanner._nmap_port_scan", new_callable=AsyncMock) as mock_port:
|
|
||||||
result = await _nmap_scan("192.168.1.0/24", run_id=run_id)
|
|
||||||
assert result == []
|
|
||||||
mock_sweep.assert_not_called()
|
|
||||||
mock_port.assert_not_called()
|
|
||||||
finally:
|
|
||||||
_cancelled_runs.discard(run_id)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_sweep_cancelled_mid_sweep_returns_empty():
|
|
||||||
"""Cancelling during Phase 1 bails before Phase 2 — no alive hosts returned."""
|
|
||||||
from app.services.scanner import _cancelled_runs, _ping_sweep, request_cancel
|
|
||||||
|
|
||||||
run_id = "cancel-during-sweep"
|
|
||||||
|
|
||||||
async def _fake_subprocess(*args, **kwargs):
|
|
||||||
proc = AsyncMock()
|
|
||||||
proc.wait = AsyncMock(return_value=1)
|
|
||||||
proc.returncode = 1
|
|
||||||
return proc
|
|
||||||
|
|
||||||
request_cancel(run_id)
|
|
||||||
try:
|
|
||||||
with patch("app.services.scanner.asyncio.create_subprocess_exec", new=_fake_subprocess), \
|
|
||||||
patch("app.services.scanner._arp_table_hosts", return_value={}):
|
|
||||||
result = await _ping_sweep("192.168.1.0/30", run_id=run_id)
|
|
||||||
assert result == {}
|
|
||||||
finally:
|
|
||||||
_cancelled_runs.discard(run_id)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_port_scan_skips_queued_hosts_when_cancelled():
|
|
||||||
"""Once cancelled, queued hosts return unscanned instead of invoking nmap."""
|
|
||||||
from app.services.scanner import _cancelled_runs, _nmap_port_scan, request_cancel
|
|
||||||
|
|
||||||
run_id = "cancel-port-scan"
|
|
||||||
alive = {
|
|
||||||
"192.168.1.10": {
|
|
||||||
"ip": "192.168.1.10", "mac": None, "hostname": None,
|
|
||||||
"os": None, "open_ports": [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
request_cancel(run_id)
|
|
||||||
try:
|
|
||||||
with patch("app.services.scanner._nmap_scan_single") as mock_single:
|
|
||||||
result = await _nmap_port_scan(alive, run_id=run_id)
|
|
||||||
mock_single.assert_not_called()
|
|
||||||
assert result[0]["ip"] == "192.168.1.10"
|
|
||||||
assert result[0]["open_ports"] == []
|
|
||||||
finally:
|
|
||||||
_cancelled_runs.discard(run_id)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _mdns_discover
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mdns_discover_returns_empty_when_zeroconf_unavailable():
|
|
||||||
from app.services.scanner import _mdns_discover
|
|
||||||
|
|
||||||
with patch("app.services.scanner._ZEROCONF_AVAILABLE", False):
|
|
||||||
result = await _mdns_discover()
|
|
||||||
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mdns_discover_returns_devices():
|
|
||||||
from app.services.scanner import _mdns_discover
|
|
||||||
|
|
||||||
mock_info = MagicMock()
|
|
||||||
mock_info.addresses = [b"\xc0\xa8\x01\x50"] # 192.168.1.80
|
|
||||||
mock_info.server = "shelly1.local."
|
|
||||||
mock_info.port = 80
|
|
||||||
mock_info.async_request = AsyncMock(return_value=True)
|
|
||||||
|
|
||||||
mock_browser = AsyncMock()
|
|
||||||
mock_browser.async_cancel = AsyncMock()
|
|
||||||
|
|
||||||
# Simulate a service being found during the sleep
|
|
||||||
captured_handler: list = []
|
|
||||||
|
|
||||||
def fake_browser(zc, types, handlers):
|
|
||||||
captured_handler.extend(handlers)
|
|
||||||
return mock_browser
|
|
||||||
|
|
||||||
from zeroconf import ServiceStateChange
|
|
||||||
|
|
||||||
async def fake_sleep(t):
|
|
||||||
# Fire the handler as if a device was discovered
|
|
||||||
for h in captured_handler:
|
|
||||||
h(None, "_shelly._tcp.local.", "Shelly1._shelly._tcp.local.", ServiceStateChange.Added)
|
|
||||||
|
|
||||||
mock_azc = AsyncMock()
|
|
||||||
mock_azc.__aenter__ = AsyncMock(return_value=mock_azc)
|
|
||||||
mock_azc.__aexit__ = AsyncMock(return_value=None)
|
|
||||||
mock_azc.zeroconf = MagicMock()
|
|
||||||
|
|
||||||
with patch("app.services.scanner._ZEROCONF_AVAILABLE", True), \
|
|
||||||
patch("app.services.scanner.AsyncZeroconf", return_value=mock_azc), \
|
|
||||||
patch("app.services.scanner.AsyncServiceBrowser", side_effect=fake_browser), \
|
|
||||||
patch("app.services.scanner.AsyncServiceInfo", return_value=mock_info), \
|
|
||||||
patch("asyncio.sleep", side_effect=fake_sleep):
|
|
||||||
result = await _mdns_discover(timeout=0.01)
|
|
||||||
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["ip"] == "192.168.1.80"
|
|
||||||
assert result[0]["hostname"] == "shelly1.local."
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _nmap_port_scan (Phase 2 concurrency)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_port_scan_returns_empty_when_no_alive_hosts():
|
|
||||||
from app.services.scanner import _nmap_port_scan
|
|
||||||
|
|
||||||
result = await _nmap_port_scan({})
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_nmap_port_scan_tolerates_single_host_exception():
|
|
||||||
"""A single per-host failure should not abort the entire Phase 2 gather."""
|
|
||||||
from app.services.scanner import _nmap_port_scan
|
|
||||||
|
|
||||||
hosts = {
|
|
||||||
"192.168.1.1": {"ip": "192.168.1.1", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
|
||||||
"192.168.1.2": {"ip": "192.168.1.2", "hostname": None, "mac": None, "os": None, "open_ports": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
def _flaky_scan(host_dict, port_spec=None):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if host_dict["ip"] == "192.168.1.1":
|
|
||||||
raise RuntimeError("simulated nmap crash")
|
|
||||||
return host_dict
|
|
||||||
|
|
||||||
with patch("app.services.scanner._nmap_scan_single", side_effect=_flaky_scan), \
|
|
||||||
patch("app.services.scanner._NMAP_AVAILABLE", True):
|
|
||||||
result = await _nmap_port_scan(hosts)
|
|
||||||
|
|
||||||
assert call_count == 2
|
|
||||||
# The crashing host is dropped; the healthy one survives
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["ip"] == "192.168.1.2"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# run_scan integration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
result = await session.execute(sa_select(PendingDevice))
|
|
||||||
devices = result.scalars().all()
|
|
||||||
|
|
||||||
assert any(d.ip == "192.168.1.5" for d in devices)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
|
|
||||||
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
node = await session.get(Node, "n1")
|
|
||||||
|
|
||||||
assert node is not None
|
|
||||||
assert node.last_scan is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
|
|
||||||
"""A node with no IP but a matching MAC still gets last_scan stamped."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
node = await session.get(Node, "n2")
|
|
||||||
|
|
||||||
assert node is not None
|
|
||||||
assert node.last_scan is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
|
|
||||||
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
node = await session.get(Node, "n3")
|
|
||||||
|
|
||||||
assert node is not None
|
|
||||||
assert node.last_scan is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_mdns_only_device_added(mem_db):
|
|
||||||
"""Devices found only by mDNS (not nmap) should appear in pending_devices."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
mdns_hosts = [{"ip": "192.168.1.80", "hostname": "shelly1.local.", "mac": None, "os": None, "open_ports": [{"port": 80, "protocol": "tcp", "banner": ""}]}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=mdns_hosts), \
|
|
||||||
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:
|
|
||||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.80"))
|
|
||||||
device = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
assert device is not None
|
|
||||||
assert device.status == "pending"
|
|
||||||
assert device.discovery_source == "mdns"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
|
|
||||||
"""A scan reconciles a prior Proxmox-imported row by MAC: fills the IP,
|
|
||||||
unions the source, keeps the vm type, and does not duplicate."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
|
|
||||||
session.add(PendingDevice(
|
|
||||||
id="pve-row", ieee_address="pve-pve1-101", ip=None,
|
|
||||||
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
|
|
||||||
discovery_source="proxmox", discovery_sources=["proxmox"],
|
|
||||||
))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
# Scan sees the same box (same MAC, different casing) with a live IP.
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.50", "hostname": "web.lan",
|
|
||||||
"mac": "BC:24:11:AA:BB:CC", "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
|
|
||||||
|
|
||||||
assert len(rows) == 1 # merged, not duplicated
|
|
||||||
row = rows[0]
|
|
||||||
assert row.ip == "192.168.1.50" # scan filled the IP
|
|
||||||
assert row.mac == "bc:24:11:aa:bb:cc" # normalized
|
|
||||||
assert row.suggested_type == "vm" # kept proxmox type
|
|
||||||
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
|
||||||
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
shared_host = {"ip": "192.168.1.10", "hostname": "device.lan", "mac": None, "os": None, "open_ports": []}
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=[shared_host]), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[shared_host]), \
|
|
||||||
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:
|
|
||||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.10"))
|
|
||||||
devices = result.scalars().all()
|
|
||||||
|
|
||||||
assert len(devices) == 1 # not duplicated
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_keeps_canvas_nodes(mem_db):
|
|
||||||
"""Hosts already on a canvas are NOT suppressed — they stay in the inventory
|
|
||||||
(badged "In N canvas" via correlation), so a re-scan still records them."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
canvas_node = Node(
|
|
||||||
id=str(uuid.uuid4()), label="PVE", type="proxmox",
|
|
||||||
ip="192.168.1.100", status="online",
|
|
||||||
)
|
|
||||||
session.add(canvas_node)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.100"))
|
|
||||||
device = result.scalar_one_or_none()
|
|
||||||
assert device is not None
|
|
||||||
assert device.status == "pending"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_skips_hidden_devices(mem_db):
|
|
||||||
"""Hosts hidden by the user must not re-appear in pending."""
|
|
||||||
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))
|
|
||||||
hidden = PendingDevice(ip="192.168.1.55", status="hidden")
|
|
||||||
session.add(hidden)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{"ip": "192.168.1.55", "hostname": None, "mac": None, "os": None, "open_ports": []}]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
result = await session.execute(
|
|
||||||
sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.55", PendingDevice.status == "pending")
|
|
||||||
)
|
|
||||||
assert result.scalar_one_or_none() is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_cancelled_marks_status_cancelled(mem_db):
|
|
||||||
"""Cancelling a running scan sets the ScanRun status to 'cancelled'."""
|
|
||||||
from app.services.scanner import request_cancel, run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
request_cancel(run_id)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", return_value=[]), \
|
|
||||||
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:
|
|
||||||
run = await session.get(ScanRun, run_id)
|
|
||||||
assert run is not None
|
|
||||||
assert run.status == "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Deep scan: port-range plumbing + HTTP probe
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_valid_port_range():
|
|
||||||
from app.services.scanner import _valid_port_range
|
|
||||||
|
|
||||||
assert _valid_port_range("8080")
|
|
||||||
assert _valid_port_range("8000-8100")
|
|
||||||
assert not _valid_port_range("8100-8000") # reversed
|
|
||||||
assert not _valid_port_range("0") # below 1
|
|
||||||
assert not _valid_port_range("70000") # above 65535
|
|
||||||
assert not _valid_port_range("abc")
|
|
||||||
assert not _valid_port_range("80,443") # not a single range
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_port_spec_default_when_empty():
|
|
||||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
|
||||||
|
|
||||||
assert _build_port_spec([]) == _EXTRA_PORTS
|
|
||||||
assert _build_port_spec(None) == _EXTRA_PORTS
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_port_spec_appends_valid_ranges():
|
|
||||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
|
||||||
|
|
||||||
spec = _build_port_spec(["8000-8100", "9000"])
|
|
||||||
assert spec == _EXTRA_PORTS + ",8000-8100,9000"
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_port_spec_drops_invalid_ranges():
|
|
||||||
from app.services.scanner import _EXTRA_PORTS, _build_port_spec
|
|
||||||
|
|
||||||
# invalid entries silently dropped; only valid kept
|
|
||||||
assert _build_port_spec(["bad", "70000"]) == _EXTRA_PORTS
|
|
||||||
assert _build_port_spec(["bad", "9000"]) == _EXTRA_PORTS + ",9000"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_deep_scan_passes_port_spec_to_nmap(mem_db):
|
|
||||||
from app.services.scanner import DeepScanOptions, run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_nmap(target, port_spec, run_id=None):
|
|
||||||
captured["port_spec"] = port_spec
|
|
||||||
return []
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", new=fake_nmap), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(
|
|
||||||
["192.168.1.0/24"], session, run_id,
|
|
||||||
deep_scan=DeepScanOptions(http_ranges=["8000-8100"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "8000-8100" in captured["port_spec"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_probe_enriches_services(mem_db):
|
|
||||||
"""With probe enabled, a custom-port service is identified via HTTP signals."""
|
|
||||||
from app.services.scanner import DeepScanOptions, run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{
|
|
||||||
"ip": "192.168.1.50", "hostname": None, "mac": None, "os": None,
|
|
||||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
|
||||||
}]
|
|
||||||
jellyfin_sig = [{
|
|
||||||
"port": 8096, "protocol": "tcp", "banner_regex": None, "http_regex": "Jellyfin",
|
|
||||||
"service_name": "Jellyfin", "icon": "🎬", "category": "media", "suggested_node_type": "server",
|
|
||||||
}]
|
|
||||||
|
|
||||||
async def fake_probe(ip, ports, verify_tls=False, concurrency=50):
|
|
||||||
return [{**p, "http_signals": {"title": "Jellyfin", "headers": {}}} for p in ports]
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.services.scanner.probe_open_ports", new=fake_probe), \
|
|
||||||
patch("app.services.fingerprint._load", return_value=jellyfin_sig), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(
|
|
||||||
["192.168.1.0/24"], session, run_id,
|
|
||||||
deep_scan=DeepScanOptions(http_probe_enabled=True),
|
|
||||||
)
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
result = await session.execute(sa_select(PendingDevice).where(PendingDevice.ip == "192.168.1.50"))
|
|
||||||
device = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
assert device is not None
|
|
||||||
assert any(s["service_name"] == "Jellyfin" for s in device.services)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_scan_no_probe_when_disabled(mem_db):
|
|
||||||
"""Probe must not be called on a standard (non-deep) scan."""
|
|
||||||
from app.services.scanner import run_scan
|
|
||||||
|
|
||||||
run_id = _make_run_id()
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_scan_run(run_id))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
nmap_hosts = [{
|
|
||||||
"ip": "192.168.1.51", "hostname": None, "mac": None, "os": None,
|
|
||||||
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": ""}],
|
|
||||||
}]
|
|
||||||
probe = AsyncMock()
|
|
||||||
|
|
||||||
async with mem_db() as session:
|
|
||||||
with patch("app.services.scanner._nmap_scan", new=AsyncMock(return_value=nmap_hosts)), \
|
|
||||||
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
|
|
||||||
patch("app.services.scanner.probe_open_ports", new=probe), \
|
|
||||||
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
|
|
||||||
await run_scan(["192.168.1.0/24"], session, run_id)
|
|
||||||
|
|
||||||
probe.assert_not_called()
|
|
||||||
@@ -5,24 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
from app.core.scheduler import (
|
from app.core.scheduler import _run_status_checks, start_scheduler, stop_scheduler
|
||||||
_run_proxmox_sync,
|
|
||||||
_run_service_checks,
|
|
||||||
_run_status_checks,
|
|
||||||
_run_zigbee_sync,
|
|
||||||
_run_zwave_sync,
|
|
||||||
reschedule_proxmox_sync,
|
|
||||||
reschedule_service_checks,
|
|
||||||
reschedule_status_checks,
|
|
||||||
reschedule_zigbee_sync,
|
|
||||||
reschedule_zwave_sync,
|
|
||||||
set_proxmox_sync_enabled,
|
|
||||||
set_service_checks_enabled,
|
|
||||||
set_zigbee_sync_enabled,
|
|
||||||
set_zwave_sync_enabled,
|
|
||||||
start_scheduler,
|
|
||||||
stop_scheduler,
|
|
||||||
)
|
|
||||||
from app.db.database import Base
|
from app.db.database import Base
|
||||||
from app.db.models import Node
|
from app.db.models import Node
|
||||||
|
|
||||||
@@ -158,10 +141,6 @@ def test_scheduler_uses_settings_interval():
|
|||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
mock_settings.status_checker_interval = 45
|
mock_settings.status_checker_interval = 45
|
||||||
mock_settings.service_check_enabled = False
|
|
||||||
mock_settings.proxmox_sync_enabled = False
|
|
||||||
mock_settings.zigbee_sync_enabled = False
|
|
||||||
mock_settings.zwave_sync_enabled = False
|
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
_, kwargs = mock_sched.add_job.call_args
|
_, kwargs = mock_sched.add_job.call_args
|
||||||
assert kwargs["seconds"] == 45
|
assert kwargs["seconds"] == 45
|
||||||
@@ -170,381 +149,9 @@ def test_scheduler_uses_settings_interval():
|
|||||||
def test_start_and_stop_scheduler():
|
def test_start_and_stop_scheduler():
|
||||||
"""Scheduler can be started and stopped without errors."""
|
"""Scheduler can be started and stopped without errors."""
|
||||||
mock_sched = MagicMock()
|
mock_sched = MagicMock()
|
||||||
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched), \
|
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.status_checker_interval = 60
|
|
||||||
mock_settings.service_check_enabled = False
|
|
||||||
mock_settings.proxmox_sync_enabled = False
|
|
||||||
mock_settings.zigbee_sync_enabled = False
|
|
||||||
mock_settings.zwave_sync_enabled = False
|
|
||||||
start_scheduler()
|
start_scheduler()
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
mock_sched.add_job.assert_called_once()
|
mock_sched.add_job.assert_called_once()
|
||||||
mock_sched.start.assert_called_once()
|
mock_sched.start.assert_called_once()
|
||||||
mock_sched.shutdown.assert_called_once()
|
mock_sched.shutdown.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Service checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_service_checks_disabled_does_nothing(mem_db):
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
|
||||||
patch("app.services.status_checker.check_services", new_callable=AsyncMock) as mock_cs:
|
|
||||||
mock_settings.service_check_enabled = False
|
|
||||||
await _run_service_checks()
|
|
||||||
mock_cs.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_service_checks_broadcasts_per_node(mem_db):
|
|
||||||
async with mem_db() as session:
|
|
||||||
node = _make_node(
|
|
||||||
ip="10.0.0.5",
|
|
||||||
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
|
|
||||||
)
|
|
||||||
session.add(node)
|
|
||||||
await session.commit()
|
|
||||||
node_id = node.id
|
|
||||||
|
|
||||||
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
|
||||||
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
|
||||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock, return_value=statuses), \
|
|
||||||
patch("app.api.routes.status.broadcast_service_status", new_callable=AsyncMock) as mock_bcast:
|
|
||||||
mock_settings.service_check_enabled = True
|
|
||||||
await _run_service_checks()
|
|
||||||
|
|
||||||
mock_bcast.assert_awaited_once()
|
|
||||||
_, kwargs = mock_bcast.call_args
|
|
||||||
assert kwargs["node_id"] == node_id
|
|
||||||
assert kwargs["services"] == statuses
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_service_checks_skips_nodes_without_services(mem_db):
|
|
||||||
async with mem_db() as session:
|
|
||||||
session.add(_make_node(ip="10.0.0.6", services=[]))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
|
|
||||||
patch("app.core.scheduler.check_services", new_callable=AsyncMock) as mock_cs:
|
|
||||||
mock_settings.service_check_enabled = True
|
|
||||||
await _run_service_checks()
|
|
||||||
mock_cs.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_service_checks_enabled_adds_and_removes_job():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.service_check_interval = 300
|
|
||||||
# Enable: no existing job -> add
|
|
||||||
mock_sched.get_job.return_value = None
|
|
||||||
set_service_checks_enabled(True)
|
|
||||||
mock_sched.add_job.assert_called_once()
|
|
||||||
# Disable: existing job -> remove
|
|
||||||
mock_sched.get_job.return_value = MagicMock()
|
|
||||||
set_service_checks_enabled(False)
|
|
||||||
mock_sched.remove_job.assert_called_once_with("service_checks")
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_scheduler_adds_service_job_when_enabled():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
|
||||||
mock_settings.status_checker_interval = 60
|
|
||||||
mock_settings.service_check_enabled = True
|
|
||||||
mock_settings.service_check_interval = 300
|
|
||||||
mock_settings.proxmox_sync_enabled = False
|
|
||||||
mock_settings.zigbee_sync_enabled = False
|
|
||||||
mock_settings.zwave_sync_enabled = False
|
|
||||||
start_scheduler()
|
|
||||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
|
||||||
assert "status_checks" in job_ids
|
|
||||||
assert "service_checks" in job_ids
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Proxmox auto-sync job
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_set_proxmox_sync_enabled_adds_and_removes_job():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.proxmox_sync_interval = 3600
|
|
||||||
mock_sched.get_job.return_value = None
|
|
||||||
set_proxmox_sync_enabled(True)
|
|
||||||
mock_sched.add_job.assert_called_once()
|
|
||||||
mock_sched.get_job.return_value = MagicMock()
|
|
||||||
set_proxmox_sync_enabled(False)
|
|
||||||
mock_sched.remove_job.assert_called_once_with("proxmox_sync")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_proxmox_sync_rejects_short_interval():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
reschedule_proxmox_sync(60)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_proxmox_sync_skips_when_disabled():
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.proxmox_sync_enabled = False
|
|
||||||
# Must return before importing/fetching anything.
|
|
||||||
await _run_proxmox_sync()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_proxmox_sync_skips_when_no_token():
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.proxmox_sync_enabled = True
|
|
||||||
mock_settings.proxmox_host = "pve"
|
|
||||||
mock_settings.proxmox_token_id = ""
|
|
||||||
mock_settings.proxmox_token_secret = ""
|
|
||||||
await _run_proxmox_sync() # no exception, no fetch
|
|
||||||
|
|
||||||
|
|
||||||
def _proxmox_settings(mock_settings):
|
|
||||||
mock_settings.proxmox_sync_enabled = True
|
|
||||||
mock_settings.proxmox_host = "pve"
|
|
||||||
mock_settings.proxmox_port = 8006
|
|
||||||
mock_settings.proxmox_token_id = "root@pam!tok"
|
|
||||||
mock_settings.proxmox_token_secret = "secret"
|
|
||||||
mock_settings.proxmox_verify_tls = False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_proxmox_sync_records_scan_run(mem_db):
|
|
||||||
"""Auto-sync must create a ScanRun (kind=proxmox) so it shows in Scan
|
|
||||||
history, then delegate to the shared background import with the run id."""
|
|
||||||
from app.db.models import ScanRun
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings,
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
|
||||||
patch(
|
|
||||||
"app.api.routes.proxmox._background_proxmox_import",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_bg,
|
|
||||||
):
|
|
||||||
_proxmox_settings(mock_settings)
|
|
||||||
await _run_proxmox_sync()
|
|
||||||
|
|
||||||
# A ScanRun row exists — the missing scan-history trace.
|
|
||||||
async with mem_db() as db:
|
|
||||||
from sqlalchemy import select
|
|
||||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
|
||||||
assert run.kind == "proxmox"
|
|
||||||
assert run.ranges == ["pve:8006"]
|
|
||||||
|
|
||||||
# Delegated to the same background flow the manual /sync-now uses, passing
|
|
||||||
# the run id + env connection settings.
|
|
||||||
mock_bg.assert_awaited_once_with(
|
|
||||||
run.id, "pve", 8006, "root@pam!tok", "secret", False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_set_zigbee_sync_enabled_adds_and_removes_job():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.zigbee_sync_interval = 3600
|
|
||||||
mock_sched.get_job.return_value = None
|
|
||||||
set_zigbee_sync_enabled(True)
|
|
||||||
mock_sched.add_job.assert_called_once()
|
|
||||||
mock_sched.get_job.return_value = MagicMock()
|
|
||||||
set_zigbee_sync_enabled(False)
|
|
||||||
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_zwave_sync_enabled_adds_and_removes_job():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched), \
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.zwave_sync_interval = 3600
|
|
||||||
mock_sched.get_job.return_value = None
|
|
||||||
set_zwave_sync_enabled(True)
|
|
||||||
mock_sched.add_job.assert_called_once()
|
|
||||||
mock_sched.get_job.return_value = MagicMock()
|
|
||||||
set_zwave_sync_enabled(False)
|
|
||||||
mock_sched.remove_job.assert_called_once_with("zwave_sync")
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_zigbee_sync_rejects_short_interval():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
reschedule_zigbee_sync(60)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_zwave_sync_rejects_short_interval():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
reschedule_zwave_sync(60)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_zigbee_sync_skips_when_disabled():
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.zigbee_sync_enabled = False
|
|
||||||
await _run_zigbee_sync() # returns before importing/fetching anything
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_zigbee_sync_skips_when_no_host():
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.zigbee_sync_enabled = True
|
|
||||||
mock_settings.zigbee_mqtt_host = ""
|
|
||||||
await _run_zigbee_sync() # no exception, no fetch
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_zwave_sync_skips_when_no_host():
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings:
|
|
||||||
mock_settings.zwave_sync_enabled = True
|
|
||||||
mock_settings.zwave_mqtt_host = ""
|
|
||||||
await _run_zwave_sync()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_zigbee_sync_records_scan_run(mem_db):
|
|
||||||
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
|
|
||||||
then delegate to the shared background import with the run id + env payload."""
|
|
||||||
from app.db.models import ScanRun
|
|
||||||
from app.schemas.zigbee import ZigbeeImportRequest
|
|
||||||
|
|
||||||
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings,
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
|
||||||
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
|
|
||||||
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
|
|
||||||
):
|
|
||||||
mock_settings.zigbee_sync_enabled = True
|
|
||||||
mock_settings.zigbee_mqtt_host = "broker"
|
|
||||||
mock_settings.zigbee_mqtt_port = 1883
|
|
||||||
await _run_zigbee_sync()
|
|
||||||
|
|
||||||
async with mem_db() as db:
|
|
||||||
from sqlalchemy import select
|
|
||||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
|
||||||
assert run.kind == "zigbee"
|
|
||||||
assert run.ranges == ["broker:1883"]
|
|
||||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_zwave_sync_records_scan_run(mem_db):
|
|
||||||
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
|
|
||||||
background import with the run id + env payload."""
|
|
||||||
from app.db.models import ScanRun
|
|
||||||
from app.schemas.zwave import ZwaveImportRequest
|
|
||||||
|
|
||||||
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("app.core.scheduler.settings") as mock_settings,
|
|
||||||
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
|
||||||
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
|
|
||||||
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
|
|
||||||
):
|
|
||||||
mock_settings.zwave_sync_enabled = True
|
|
||||||
mock_settings.zwave_mqtt_host = "broker"
|
|
||||||
mock_settings.zwave_mqtt_port = 1883
|
|
||||||
await _run_zwave_sync()
|
|
||||||
|
|
||||||
async with mem_db() as db:
|
|
||||||
from sqlalchemy import select
|
|
||||||
run = (await db.execute(select(ScanRun))).scalars().one()
|
|
||||||
assert run.kind == "zwave"
|
|
||||||
assert run.ranges == ["broker:1883"]
|
|
||||||
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_zigbee_sync_noop_when_not_running():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = False
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
|
||||||
reschedule_zigbee_sync(600)
|
|
||||||
mock_sched.reschedule_job.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_scheduler_adds_mesh_jobs_when_enabled():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
with patch("app.core.scheduler.settings") as mock_settings, \
|
|
||||||
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
|
||||||
mock_settings.status_checker_interval = 60
|
|
||||||
mock_settings.service_check_enabled = False
|
|
||||||
mock_settings.proxmox_sync_enabled = False
|
|
||||||
mock_settings.zigbee_sync_enabled = True
|
|
||||||
mock_settings.zigbee_sync_interval = 3600
|
|
||||||
mock_settings.zwave_sync_enabled = True
|
|
||||||
mock_settings.zwave_sync_interval = 3600
|
|
||||||
start_scheduler()
|
|
||||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
|
||||||
assert "zigbee_sync" in job_ids
|
|
||||||
assert "zwave_sync" in job_ids
|
|
||||||
|
|
||||||
|
|
||||||
# --- reschedule_* validation and not-running guards ---
|
|
||||||
|
|
||||||
def test_reschedule_status_checks_rejects_short_interval():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
reschedule_status_checks(5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_status_checks_noop_when_not_running():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = False
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
|
||||||
reschedule_status_checks(60)
|
|
||||||
mock_sched.reschedule_job.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_status_checks_updates_running_job():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
|
||||||
reschedule_status_checks(60)
|
|
||||||
mock_sched.reschedule_job.assert_called_once_with(
|
|
||||||
"status_checks", trigger="interval", seconds=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_service_checks_rejects_short_interval():
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
reschedule_service_checks(10)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_service_checks_updates_when_job_exists():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = True
|
|
||||||
mock_sched.get_job.return_value = MagicMock()
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
|
||||||
reschedule_service_checks(60)
|
|
||||||
mock_sched.reschedule_job.assert_called_once_with(
|
|
||||||
"service_checks", trigger="interval", seconds=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_reschedule_proxmox_sync_noop_when_not_running():
|
|
||||||
mock_sched = MagicMock()
|
|
||||||
mock_sched.running = False
|
|
||||||
with patch("app.core.scheduler.scheduler", mock_sched):
|
|
||||||
reschedule_proxmox_sync(600)
|
|
||||||
mock_sched.reschedule_job.assert_not_called()
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
"""Tests for GET/POST /api/v1/settings."""
|
|
||||||
import json
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_settings_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.get("/api/v1/settings")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_settings_returns_interval(client: AsyncClient, headers):
|
|
||||||
res = await client.get("/api/v1/settings", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert "interval_seconds" in data
|
|
||||||
assert isinstance(data["interval_seconds"], int)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
|
||||||
with patch("app.api.routes.settings.settings") as mock_settings:
|
|
||||||
mock_settings.status_checker_interval = 60
|
|
||||||
mock_settings.save_overrides = lambda: None
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/settings",
|
|
||||||
json={"interval_seconds": 120},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert res.json()["interval_seconds"] == 120
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_settings_requires_auth(client: AsyncClient):
|
|
||||||
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
|
||||||
res = await client.get("/api/v1/settings", headers=headers)
|
|
||||||
data = res.json()
|
|
||||||
assert "service_check_enabled" in data
|
|
||||||
assert "service_check_interval" in data
|
|
||||||
assert isinstance(data["service_check_enabled"], bool)
|
|
||||||
assert isinstance(data["service_check_interval"], int)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
|
||||||
with patch("app.api.routes.settings.settings") as mock_settings:
|
|
||||||
mock_settings.save_overrides = lambda: None
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/settings",
|
|
||||||
json={
|
|
||||||
"interval_seconds": 60,
|
|
||||||
"service_check_enabled": True,
|
|
||||||
"service_check_interval": 600,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body["service_check_enabled"] is True
|
|
||||||
assert body["service_check_interval"] == 600
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/settings",
|
|
||||||
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_proxmox_connection_config_is_env_only_never_from_overrides(tmp_path):
|
|
||||||
"""Connection config (host/port/verify_tls) is env-only: a stale value in
|
|
||||||
scan_config.json must be ignored so it can never clobber the env. Only the
|
|
||||||
auto-sync activation is read back."""
|
|
||||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
||||||
s.proxmox_host = "pve.local" # as if set from env
|
|
||||||
s.proxmox_port = 8006
|
|
||||||
s.proxmox_verify_tls = True
|
|
||||||
(tmp_path / "scan_config.json").write_text(json.dumps({
|
|
||||||
"proxmox_host": "stale-host",
|
|
||||||
"proxmox_port": 9999,
|
|
||||||
"proxmox_verify_tls": False,
|
|
||||||
"proxmox_sync_enabled": True,
|
|
||||||
"proxmox_sync_interval": 600,
|
|
||||||
}))
|
|
||||||
s.load_overrides()
|
|
||||||
# Connection config untouched by the file (env values survive).
|
|
||||||
assert s.proxmox_host == "pve.local"
|
|
||||||
assert s.proxmox_port == 8006
|
|
||||||
assert s.proxmox_verify_tls is True
|
|
||||||
# Auto-sync activation is the only thing loaded.
|
|
||||||
assert s.proxmox_sync_enabled is True
|
|
||||||
assert s.proxmox_sync_interval == 600
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_overrides_omits_proxmox_connection_config(tmp_path):
|
|
||||||
"""save_overrides must never write host/port/verify_tls (nor the token) —
|
|
||||||
only the sync activation. This is what prevents the dual source of truth."""
|
|
||||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
||||||
s.proxmox_host = "pve.local"
|
|
||||||
s.proxmox_sync_enabled = True
|
|
||||||
s.proxmox_sync_interval = 900
|
|
||||||
s.save_overrides()
|
|
||||||
written = json.loads((tmp_path / "scan_config.json").read_text())
|
|
||||||
assert "proxmox_host" not in written
|
|
||||||
assert "proxmox_port" not in written
|
|
||||||
assert "proxmox_verify_tls" not in written
|
|
||||||
assert "proxmox_token_id" not in written
|
|
||||||
assert "proxmox_token_secret" not in written
|
|
||||||
assert written["proxmox_sync_enabled"] is True
|
|
||||||
assert written["proxmox_sync_interval"] == 900
|
|
||||||
|
|
||||||
|
|
||||||
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
|
|
||||||
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
|
|
||||||
env-only: stale values in scan_config.json must be ignored. Only the
|
|
||||||
auto-sync activation is read back."""
|
|
||||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
||||||
s.zigbee_mqtt_host = "broker.local"
|
|
||||||
s.zwave_mqtt_host = "broker.local"
|
|
||||||
(tmp_path / "scan_config.json").write_text(json.dumps({
|
|
||||||
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
|
|
||||||
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
|
|
||||||
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
|
|
||||||
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
|
|
||||||
}))
|
|
||||||
s.load_overrides()
|
|
||||||
assert s.zigbee_mqtt_host == "broker.local"
|
|
||||||
assert s.zwave_mqtt_host == "broker.local"
|
|
||||||
assert s.zigbee_sync_enabled is True
|
|
||||||
assert s.zigbee_sync_interval == 600
|
|
||||||
assert s.zwave_sync_enabled is True
|
|
||||||
assert s.zwave_sync_interval == 700
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_overrides_omits_mesh_credentials(tmp_path):
|
|
||||||
"""save_overrides must never write MQTT host/credentials — only the sync
|
|
||||||
activation. Prevents the dual source of truth and leaking secrets to disk."""
|
|
||||||
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
||||||
s.zigbee_mqtt_host = "broker.local"
|
|
||||||
s.zigbee_mqtt_password = "secret"
|
|
||||||
s.zigbee_sync_enabled = True
|
|
||||||
s.zigbee_sync_interval = 900
|
|
||||||
s.zwave_mqtt_password = "secret"
|
|
||||||
s.zwave_sync_interval = 1200
|
|
||||||
s.save_overrides()
|
|
||||||
raw = (tmp_path / "scan_config.json").read_text()
|
|
||||||
assert "secret" not in raw
|
|
||||||
written = json.loads(raw)
|
|
||||||
assert "zigbee_mqtt_host" not in written
|
|
||||||
assert "zigbee_mqtt_password" not in written
|
|
||||||
assert "zwave_mqtt_password" not in written
|
|
||||||
assert written["zigbee_sync_enabled"] is True
|
|
||||||
assert written["zigbee_sync_interval"] == 900
|
|
||||||
assert written["zwave_sync_interval"] == 1200
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"""Integrity + matching tests against the real service_signatures.json."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.fingerprint import _load, match_service
|
|
||||||
|
|
||||||
_NODE_TYPES = {
|
|
||||||
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
|
|
||||||
"nas", "iot", "ap", "camera", "generic",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def signatures():
|
|
||||||
return _load()
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_entries_well_formed(signatures):
|
|
||||||
for sig in signatures:
|
|
||||||
# port is an int or explicitly null (port-agnostic)
|
|
||||||
assert sig.get("port") is None or isinstance(sig["port"], int)
|
|
||||||
assert isinstance(sig["service_name"], str) and sig["service_name"]
|
|
||||||
assert sig["suggested_node_type"] in _NODE_TYPES
|
|
||||||
if sig.get("banner_regex"):
|
|
||||||
re.compile(sig["banner_regex"])
|
|
||||||
if sig.get("http_regex"):
|
|
||||||
re.compile(sig["http_regex"])
|
|
||||||
|
|
||||||
|
|
||||||
def test_port_agnostic_entries_require_http_regex(signatures):
|
|
||||||
for sig in signatures:
|
|
||||||
if sig.get("port") is None:
|
|
||||||
assert sig.get("http_regex"), f"port:null entry needs http_regex: {sig}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_popular_apps_have_port_agnostic_signatures(signatures):
|
|
||||||
names = {s["service_name"] for s in signatures if s.get("port") is None}
|
|
||||||
for expected in {
|
|
||||||
"Jellyfin", "Plex", "Home Assistant", "Portainer", "Pi-hole",
|
|
||||||
"AdGuard Home", "Grafana", "Nextcloud", "Vaultwarden", "Sonarr",
|
|
||||||
}:
|
|
||||||
assert expected in names, f"missing port-agnostic signature for {expected}"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(("title", "expected"), [
|
|
||||||
("Jellyfin", "Jellyfin"),
|
|
||||||
("Home Assistant", "Home Assistant"),
|
|
||||||
("Portainer", "Portainer"),
|
|
||||||
("Vaultwarden Web Vault", "Vaultwarden"),
|
|
||||||
("Pi-hole - Dashboard", "Pi-hole"),
|
|
||||||
("Audiobookshelf", "Audiobookshelf"),
|
|
||||||
])
|
|
||||||
def test_custom_port_identified_via_http_title(title, expected):
|
|
||||||
# A service on a non-standard port, recognised purely by its HTML title.
|
|
||||||
sig = match_service(58000, "tcp", banner=None, http_signals={"title": title, "headers": {}})
|
|
||||||
assert sig is not None
|
|
||||||
assert sig["service_name"] == expected
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_port_without_probe_is_unknown():
|
|
||||||
# Same custom port, deep scan off → no signal → no port-agnostic match.
|
|
||||||
assert match_service(58000, "tcp", banner=None, http_signals=None) is None
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""API tests for /api/v1/stats/* (gethomepage widget)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.models import Node, PendingDevice, ScanRun
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _reset_homepage_key():
|
|
||||||
original = settings.homepage_api_key
|
|
||||||
settings.homepage_api_key = ""
|
|
||||||
yield
|
|
||||||
settings.homepage_api_key = original
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_summary_disabled_when_key_unset(client: AsyncClient) -> None:
|
|
||||||
res = await client.get("/api/v1/stats/summary")
|
|
||||||
assert res.status_code == 403
|
|
||||||
assert "disabled" in res.json()["detail"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_summary_rejects_missing_header(client: AsyncClient) -> None:
|
|
||||||
settings.homepage_api_key = "topsecret"
|
|
||||||
res = await client.get("/api/v1/stats/summary")
|
|
||||||
assert res.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_summary_rejects_wrong_key(client: AsyncClient) -> None:
|
|
||||||
settings.homepage_api_key = "topsecret"
|
|
||||||
res = await client.get(
|
|
||||||
"/api/v1/stats/summary", headers={"X-API-Key": "wrong"}
|
|
||||||
)
|
|
||||||
assert res.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_summary_empty_db(client: AsyncClient) -> None:
|
|
||||||
settings.homepage_api_key = "topsecret"
|
|
||||||
res = await client.get(
|
|
||||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body == {
|
|
||||||
"nodes": 0,
|
|
||||||
"online": 0,
|
|
||||||
"offline": 0,
|
|
||||||
"unknown": 0,
|
|
||||||
"pending_devices": 0,
|
|
||||||
"zigbee_devices": 0,
|
|
||||||
"last_scan_at": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_summary_aggregates_counts(
|
|
||||||
client: AsyncClient, db_session: AsyncSession
|
|
||||||
) -> None:
|
|
||||||
settings.homepage_api_key = "topsecret"
|
|
||||||
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
|
|
||||||
db_session.add_all([
|
|
||||||
Node(type="server", label="A", status="online"),
|
|
||||||
Node(type="server", label="B", status="online"),
|
|
||||||
Node(type="server", label="C", status="offline"),
|
|
||||||
Node(type="server", label="D", status="unknown"),
|
|
||||||
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
|
|
||||||
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
|
|
||||||
PendingDevice(ip="10.0.0.1", status="pending"),
|
|
||||||
PendingDevice(ip="10.0.0.2", status="pending"),
|
|
||||||
PendingDevice(ip="10.0.0.3", status="hidden"), # excluded
|
|
||||||
ScanRun(status="success", finished_at=finished),
|
|
||||||
ScanRun(status="success",
|
|
||||||
finished_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc)),
|
|
||||||
])
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
res = await client.get(
|
|
||||||
"/api/v1/stats/summary", headers={"X-API-Key": "topsecret"}
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
body = res.json()
|
|
||||||
assert body["nodes"] == 6
|
|
||||||
assert body["online"] == 4
|
|
||||||
assert body["offline"] == 1
|
|
||||||
assert body["unknown"] == 1
|
|
||||||
assert body["pending_devices"] == 2
|
|
||||||
assert body["zigbee_devices"] == 2
|
|
||||||
# SQLite returns naive datetimes; compare prefix only.
|
|
||||||
assert body["last_scan_at"] is not None
|
|
||||||
assert body["last_scan_at"].startswith("2026-05-14T10:00:00")
|
|
||||||
@@ -5,13 +5,7 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from starlette.websockets import WebSocketDisconnect
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.api.routes.status import (
|
from app.api.routes.status import _connections, broadcast_scan_update, broadcast_status
|
||||||
_connections,
|
|
||||||
_drop,
|
|
||||||
broadcast_scan_update,
|
|
||||||
broadcast_service_status,
|
|
||||||
broadcast_status,
|
|
||||||
)
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -161,63 +155,3 @@ async def test_broadcast_no_connections():
|
|||||||
assert len(_connections) == 0
|
assert len(_connections) == 0
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
await broadcast_status(node_id="n", status="online", checked_at="t")
|
||||||
await broadcast_scan_update(run_id="r", devices_found=0)
|
await broadcast_scan_update(run_id="r", devices_found=0)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# broadcast_service_status
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_service_status_payload():
|
|
||||||
received: list[str] = []
|
|
||||||
|
|
||||||
class FakeWS:
|
|
||||||
async def send_text(self, text: str) -> None:
|
|
||||||
received.append(text)
|
|
||||||
|
|
||||||
fake = FakeWS()
|
|
||||||
_connections.append(fake)
|
|
||||||
try:
|
|
||||||
await broadcast_service_status(
|
|
||||||
node_id="node-7",
|
|
||||||
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
|
|
||||||
checked_at="2024-01-01T00:00:00",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
_drop(fake)
|
|
||||||
|
|
||||||
msg = json.loads(received[0])
|
|
||||||
assert msg["type"] == "service_status"
|
|
||||||
assert msg["node_id"] == "node-7"
|
|
||||||
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _drop — idempotent connection removal (regression for double-remove crash)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_drop_is_idempotent():
|
|
||||||
"""Dropping a connection twice must not raise (was a ValueError crash)."""
|
|
||||||
class FakeWS:
|
|
||||||
pass
|
|
||||||
|
|
||||||
fake = FakeWS()
|
|
||||||
_connections.append(fake)
|
|
||||||
_drop(fake)
|
|
||||||
_drop(fake) # second drop must be a no-op
|
|
||||||
assert fake not in _connections
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_broadcast_dead_connection_dropped_once_safely():
|
|
||||||
"""A send failure removes the dead socket without a double-remove crash."""
|
|
||||||
class DeadWS:
|
|
||||||
async def send_text(self, _: str) -> None:
|
|
||||||
raise RuntimeError("disconnected")
|
|
||||||
|
|
||||||
dead = DeadWS()
|
|
||||||
_connections.append(dead)
|
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
|
||||||
# A second broadcast must not raise even though dead is already gone.
|
|
||||||
await broadcast_status(node_id="n", status="online", checked_at="t")
|
|
||||||
assert dead not in _connections
|
|
||||||
|
|||||||
@@ -3,26 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import (
|
from app.services.status_checker import _tcp_connect, check_node
|
||||||
_http_get,
|
|
||||||
_ping,
|
|
||||||
_tcp_connect,
|
|
||||||
check_node,
|
|
||||||
check_service,
|
|
||||||
check_services,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _mock_httpx_client(status_code):
|
|
||||||
"""Build a stand-in for httpx.AsyncClient whose GET returns status_code."""
|
|
||||||
resp = MagicMock()
|
|
||||||
resp.status_code = status_code
|
|
||||||
client = MagicMock()
|
|
||||||
client.get = AsyncMock(return_value=resp)
|
|
||||||
ctx = MagicMock()
|
|
||||||
ctx.__aenter__ = AsyncMock(return_value=client)
|
|
||||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
return MagicMock(return_value=ctx)
|
|
||||||
|
|
||||||
# --- check_node dispatcher ---
|
# --- check_node dispatcher ---
|
||||||
|
|
||||||
@@ -168,172 +149,6 @@ async def test_check_node_exception_returns_offline():
|
|||||||
assert result["response_time_ms"] is None
|
assert result["response_time_ms"] is None
|
||||||
|
|
||||||
|
|
||||||
# --- _ping platform args ---
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_uses_unix_args_on_non_windows():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("192.168.1.1")
|
|
||||||
|
|
||||||
assert "-c" in captured["args"]
|
|
||||||
assert "-W" in captured["args"]
|
|
||||||
assert "-n" not in captured["args"]
|
|
||||||
# 2 probes so a single dropped packet doesn't flap the node offline
|
|
||||||
c_idx = captured["args"].index("-c")
|
|
||||||
assert captured["args"][c_idx + 1] == "2"
|
|
||||||
# Linux: -W is in seconds; 2s is the intended timeout
|
|
||||||
w_idx = captured["args"].index("-W")
|
|
||||||
assert captured["args"][w_idx + 1] == "2"
|
|
||||||
# IPv4 target → no -6 flag
|
|
||||||
assert "-6" not in captured["args"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_uses_macos_millisecond_timeout():
|
|
||||||
"""macOS ping(8) -W is milliseconds, not seconds. 1ms would fail any RTT >1ms."""
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("192.168.1.1")
|
|
||||||
|
|
||||||
assert "-c" in captured["args"]
|
|
||||||
assert "-W" in captured["args"]
|
|
||||||
w_idx = captured["args"].index("-W")
|
|
||||||
assert captured["args"][w_idx + 1] == "2000"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_uses_windows_args_on_win32():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("192.168.1.1")
|
|
||||||
|
|
||||||
assert "-n" in captured["args"]
|
|
||||||
assert "-w" in captured["args"]
|
|
||||||
assert "-c" not in captured["args"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- _ping IPv6 support ---
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_ipv6_linux_uses_dash6():
|
|
||||||
"""IPv6-only devices (e.g. Alexa) need ping -6 on Linux."""
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "linux"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("fe80::1")
|
|
||||||
|
|
||||||
assert "-6" in captured["args"]
|
|
||||||
assert captured["args"][-1] == "fe80::1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_ipv6_macos_uses_ping6():
|
|
||||||
"""macOS ships a separate ping6 binary for IPv6 targets."""
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "darwin"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("2001:db8::1")
|
|
||||||
|
|
||||||
assert captured["args"][0] == "ping6"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_ping_ipv6_windows_uses_dash6():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_exec(*args, **kwargs):
|
|
||||||
captured["args"] = args
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.returncode = 0
|
|
||||||
proc.wait = AsyncMock()
|
|
||||||
return proc
|
|
||||||
|
|
||||||
with patch("app.services.status_checker.sys.platform", "win32"), \
|
|
||||||
patch("asyncio.create_subprocess_exec", side_effect=fake_exec):
|
|
||||||
await _ping("2001:db8::1")
|
|
||||||
|
|
||||||
assert "-6" in captured["args"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_ipv6_detection():
|
|
||||||
from app.services.status_checker import _is_ipv6
|
|
||||||
|
|
||||||
assert _is_ipv6("fe80::1") is True
|
|
||||||
assert _is_ipv6("2001:db8::1") is True
|
|
||||||
assert _is_ipv6("[2001:db8::1]") is True
|
|
||||||
assert _is_ipv6("192.168.1.1") is False
|
|
||||||
assert _is_ipv6("example.local") is False
|
|
||||||
|
|
||||||
|
|
||||||
# --- check_node target validation ---
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_node_rejects_flag_like_target():
|
|
||||||
"""A target starting with '-' must never reach subprocess invocation."""
|
|
||||||
from app.services.status_checker import check_node
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
|
||||||
result = await check_node("ping", "-O", None)
|
|
||||||
|
|
||||||
mock_exec.assert_not_called()
|
|
||||||
assert result["status"] == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_node_rejects_flag_like_ip():
|
|
||||||
from app.services.status_checker import check_node
|
|
||||||
|
|
||||||
with patch("asyncio.create_subprocess_exec") as mock_exec:
|
|
||||||
result = await check_node("ping", None, "-O")
|
|
||||||
|
|
||||||
mock_exec.assert_not_called()
|
|
||||||
assert result["status"] == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
# --- _tcp_connect ---
|
# --- _tcp_connect ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -361,149 +176,3 @@ async def test_tcp_connect_os_error():
|
|||||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||||
result = await _tcp_connect("192.168.1.1", 9999)
|
result = await _tcp_connect("192.168.1.1", 9999)
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
# --- check_service ---
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_no_host_is_unknown():
|
|
||||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, None) == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_flag_host_is_unknown():
|
|
||||||
assert await check_service({"port": 80, "protocol": "tcp", "service_name": "http"}, "-O") == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_udp_is_unknown():
|
|
||||||
assert await check_service({"port": 53, "protocol": "udp", "service_name": "dns"}, "10.0.0.1") == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_portless_non_web_is_unknown():
|
|
||||||
svc = {"protocol": "tcp", "service_name": "thing"}
|
|
||||||
assert await check_service(svc, "10.0.0.1") == "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_web_uses_http_get():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_http_get(url, verify=False):
|
|
||||||
captured["url"] = url
|
|
||||||
return True
|
|
||||||
|
|
||||||
svc = {"port": 8080, "protocol": "tcp", "service_name": "http"}
|
|
||||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
|
||||||
result = await check_service(svc, "10.0.0.1")
|
|
||||||
assert result == "online"
|
|
||||||
assert captured["url"] == "http://10.0.0.1:8080"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_https_port_uses_https_scheme():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_http_get(url, verify=False):
|
|
||||||
captured["url"] = url
|
|
||||||
return True
|
|
||||||
|
|
||||||
svc = {"port": 443, "protocol": "tcp", "service_name": "web"}
|
|
||||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
|
||||||
await check_service(svc, "10.0.0.1")
|
|
||||||
assert captured["url"].startswith("https://")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_web_offline_when_http_fails():
|
|
||||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
|
||||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=False):
|
|
||||||
assert await check_service(svc, "10.0.0.1") == "offline"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_non_http_port_is_unknown():
|
|
||||||
"""Non-HTTP ports (DB, mail, …) stay grey — no TCP check, no red flap."""
|
|
||||||
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres"}
|
|
||||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp, \
|
|
||||||
patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_http:
|
|
||||||
result = await check_service(svc, "10.0.0.1")
|
|
||||||
assert result == "unknown"
|
|
||||||
mock_tcp.assert_not_called()
|
|
||||||
mock_http.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_ssh_port_22_is_unknown():
|
|
||||||
"""SSH (port 22) is never checked — keep it grey, not red/green."""
|
|
||||||
svc = {"port": 22, "protocol": "tcp", "service_name": "ssh"}
|
|
||||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock) as mock_tcp:
|
|
||||||
result = await check_service(svc, "10.0.0.1")
|
|
||||||
assert result == "unknown"
|
|
||||||
mock_tcp.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_ipv6_brackets_url_host():
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
async def fake_http_get(url, verify=False):
|
|
||||||
captured["url"] = url
|
|
||||||
return True
|
|
||||||
|
|
||||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
|
||||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
|
||||||
await check_service(svc, "2001:db8::1")
|
|
||||||
assert captured["url"] == "http://[2001:db8::1]:80"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_services_returns_status_per_service():
|
|
||||||
services = [
|
|
||||||
{"port": 80, "protocol": "tcp", "service_name": "http"},
|
|
||||||
{"port": 5432, "protocol": "tcp", "service_name": "postgres"},
|
|
||||||
]
|
|
||||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
|
||||||
results = await check_services("10.0.0.1", services)
|
|
||||||
assert results == [
|
|
||||||
{"port": 80, "protocol": "tcp", "status": "online"},
|
|
||||||
{"port": 5432, "protocol": "tcp", "status": "unknown"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_services_empty_list():
|
|
||||||
assert await check_services("10.0.0.1", []) == []
|
|
||||||
|
|
||||||
|
|
||||||
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_http_get_true_on_2xx():
|
|
||||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
|
|
||||||
assert await _http_get("http://host") is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_http_get_true_on_4xx():
|
|
||||||
# A 4xx means the server is up and answering — still "online".
|
|
||||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(404)):
|
|
||||||
assert await _http_get("http://host") is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_http_get_false_on_5xx():
|
|
||||||
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(503)):
|
|
||||||
assert await _http_get("http://host") is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_check_service_http_exception_returns_offline():
|
|
||||||
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
|
||||||
with patch(
|
|
||||||
"app.services.status_checker._http_get",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
side_effect=RuntimeError("connection refused"),
|
|
||||||
):
|
|
||||||
assert await check_service(svc, "10.0.0.1") == "offline"
|
|
||||||
|
|||||||
@@ -1,845 +0,0 @@
|
|||||||
"""API endpoint tests for /api/v1/zigbee/*."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Fixtures
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zigbee/test-connection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
|
||||||
mock_conn.return_value = True
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["connected"] is True
|
|
||||||
assert "success" in data["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
|
||||||
mock_conn.side_effect = ConnectionError("Connection refused")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/test-connection",
|
|
||||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["connected"] is False
|
|
||||||
assert "refused" in data["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422 # pydantic validation error
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zigbee/import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_SAMPLE_NODES = [
|
|
||||||
{
|
|
||||||
"id": "0x00000000",
|
|
||||||
"label": "Coordinator",
|
|
||||||
"type": "zigbee_coordinator",
|
|
||||||
"ieee_address": "0x00000000",
|
|
||||||
"friendly_name": "Coordinator",
|
|
||||||
"device_type": "Coordinator",
|
|
||||||
"model": None,
|
|
||||||
"vendor": None,
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0x00000001",
|
|
||||||
"label": "router_1",
|
|
||||||
"type": "zigbee_router",
|
|
||||||
"ieee_address": "0x00000001",
|
|
||||||
"friendly_name": "router_1",
|
|
||||||
"device_type": "Router",
|
|
||||||
"model": "CC2530",
|
|
||||||
"vendor": "Texas Instruments",
|
|
||||||
"lqi": 230,
|
|
||||||
"parent_id": "0x00000000",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
_SAMPLE_EDGES = [
|
|
||||||
{"source": "0x00000000", "target": "0x00000001"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "localhost",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"base_topic": "zigbee2mqtt",
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["device_count"] == 2
|
|
||||||
assert len(data["nodes"]) == 2
|
|
||||||
assert len(data["edges"]) == 1
|
|
||||||
coordinator = next(n for n in data["nodes"] if n["type"] == "zigbee_coordinator")
|
|
||||||
assert coordinator["ieee_address"] == "0x00000000"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_with_credentials(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.return_value = ([], [])
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "localhost",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"mqtt_username": "admin",
|
|
||||||
"mqtt_password": "secret",
|
|
||||||
"base_topic": "z2m",
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
mock_fetch.assert_called_once_with(
|
|
||||||
mqtt_host="localhost",
|
|
||||||
mqtt_port=1883,
|
|
||||||
base_topic="z2m",
|
|
||||||
username="admin",
|
|
||||||
password="secret",
|
|
||||||
tls=False,
|
|
||||||
tls_insecure=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 502
|
|
||||||
assert "broker unreachable" in res.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = TimeoutError("timed out")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 504
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = ValueError("malformed response")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_empty_network(client: AsyncClient, headers: dict) -> None:
|
|
||||||
"""An empty Zigbee network (coordinator only) is a valid response."""
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.return_value = ([], [])
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["device_count"] == 0
|
|
||||||
assert data["nodes"] == []
|
|
||||||
assert data["edges"] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_missing_mqtt_host(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={"mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_with_tls_passes_flags(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.fetch_networkmap") as mock_fetch:
|
|
||||||
mock_fetch.return_value = ([], [])
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "broker.example.com",
|
|
||||||
"mqtt_port": 8883,
|
|
||||||
"mqtt_tls": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
kwargs = mock_fetch.call_args.kwargs
|
|
||||||
assert kwargs["tls"] is True
|
|
||||||
assert kwargs["tls_insecure"] is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "broker.example.com",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"mqtt_tls": False,
|
|
||||||
"mqtt_tls_insecure": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zigbee/import-pending
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_PENDING_NODES = [
|
|
||||||
{
|
|
||||||
"id": "0xCOORD",
|
|
||||||
"label": "Coordinator",
|
|
||||||
"type": "zigbee_coordinator",
|
|
||||||
"ieee_address": "0xCOORD",
|
|
||||||
"friendly_name": "Coordinator",
|
|
||||||
"device_type": "Coordinator",
|
|
||||||
"model": None,
|
|
||||||
"vendor": None,
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0xR1",
|
|
||||||
"label": "router_1",
|
|
||||||
"type": "zigbee_router",
|
|
||||||
"ieee_address": "0xR1",
|
|
||||||
"friendly_name": "router_1",
|
|
||||||
"device_type": "Router",
|
|
||||||
"model": "CC2530",
|
|
||||||
"vendor": "TI",
|
|
||||||
"lqi": 220,
|
|
||||||
"parent_id": "0xCOORD",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "0xE1",
|
|
||||||
"label": "bulb_kitchen",
|
|
||||||
"type": "zigbee_enddevice",
|
|
||||||
"ieee_address": "0xE1",
|
|
||||||
"friendly_name": "bulb_kitchen",
|
|
||||||
"device_type": "EndDevice",
|
|
||||||
"model": "TRADFRI",
|
|
||||||
"vendor": "IKEA",
|
|
||||||
"lqi": 180,
|
|
||||||
"parent_id": "0xR1",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
_PENDING_EDGES = [
|
|
||||||
{"source": "0xCOORD", "target": "0xR1"},
|
|
||||||
{"source": "0xR1", "target": "0xE1"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
|
||||||
client: AsyncClient, headers: dict
|
|
||||||
) -> None:
|
|
||||||
"""Endpoint returns a ScanRun (kind=zigbee, status=running) immediately;
|
|
||||||
the actual networkmap fetch + pending persist runs in the background."""
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"app.api.routes.zigbee._background_zigbee_import",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import-pending",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
run = res.json()
|
|
||||||
assert run["kind"] == "zigbee"
|
|
||||||
assert run["status"] == "running"
|
|
||||||
assert run["ranges"] == ["localhost:1883"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_coordinator_goes_to_pending(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
assert result.device_count == 3
|
|
||||||
assert result.pending_created == 3 # coordinator included now
|
|
||||||
assert result.pending_updated == 0
|
|
||||||
assert result.coordinator is None # not auto-placed
|
|
||||||
assert result.coordinator_already_existed is False
|
|
||||||
assert result.links_recorded == 2
|
|
||||||
|
|
||||||
# No canvas Node auto-created for the coordinator.
|
|
||||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
|
||||||
assert nodes == []
|
|
||||||
# Coordinator sits in the pending inventory.
|
|
||||||
coord = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert coord.status == "pending"
|
|
||||||
assert coord.suggested_type == "zigbee_coordinator"
|
|
||||||
assert coord.device_subtype == "Coordinator"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_idempotent_updates_existing(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["lqi"] = 99
|
|
||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
assert result.pending_created == 0
|
|
||||||
assert result.pending_updated == 3 # coordinator upserts too
|
|
||||||
assert result.coordinator_already_existed is False
|
|
||||||
assert result.links_recorded == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_replaces_links(db_session) -> None:
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import PendingDeviceLink
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
new_edges = [{"source": "0xCOORD", "target": "0xR1"}]
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
|
||||||
|
|
||||||
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("0xCOORD", "0xR1")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
|
|
||||||
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
|
||||||
nodes_with_meta[0]["vendor"] = "TI"
|
|
||||||
nodes_with_meta[0]["model"] = "CC2652"
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
|
||||||
|
|
||||||
coord = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert coord.vendor == "TI"
|
|
||||||
assert coord.model == "CC2652"
|
|
||||||
assert coord.suggested_type == "zigbee_coordinator"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""A device on canvas but missing its inventory row gets one backfilled
|
|
||||||
(status="approved"), so it shows in the inventory list. Node props still
|
|
||||||
refresh with the latest Vendor/Model/LQI.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
# Simulate: router was approved earlier → exists as a canvas Node, but with
|
|
||||||
# no matching pending_devices row (e.g. a legacy auto-placed device).
|
|
||||||
approved = Node(
|
|
||||||
label="router_1",
|
|
||||||
type="zigbee_router",
|
|
||||||
status="online",
|
|
||||||
check_method="none",
|
|
||||||
ieee_address="0xR1",
|
|
||||||
services=[],
|
|
||||||
properties=[],
|
|
||||||
)
|
|
||||||
db_session.add(approved)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["lqi"] = 250 # new LQI from re-import
|
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
# An inventory row is backfilled as "approved" (it is on a canvas).
|
|
||||||
inv = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert inv.status == "approved"
|
|
||||||
assert inv.suggested_type == "zigbee_router"
|
|
||||||
assert inv.device_subtype == "Router"
|
|
||||||
|
|
||||||
# Node properties got refreshed.
|
|
||||||
refreshed = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
|
||||||
).scalar_one()
|
|
||||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
|
||||||
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
|
|
||||||
# Brand-new props on an existing Node start hidden.
|
|
||||||
assert all(p["visible"] is False for p in refreshed.properties)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_preserves_hidden_inventory_for_approved_node(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""If the on-canvas device already has a hidden inventory row, re-import
|
|
||||||
refreshes its metadata but must NOT flip it back to approved/visible."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
db_session.add(Node(
|
|
||||||
label="router_1", type="zigbee_router", status="online",
|
|
||||||
check_method="none", ieee_address="0xR1", services=[], properties=[],
|
|
||||||
))
|
|
||||||
db_session.add(PendingDevice(
|
|
||||||
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
|
|
||||||
device_subtype="Router", status="hidden", discovery_source="zigbee",
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
inv = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert inv.status == "hidden" # stays hidden
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_revives_orphaned_approved_device(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""Regression for #167: approve → delete node → re-import must re-list device.
|
|
||||||
|
|
||||||
When a device was approved (PendingDevice.status="approved") and its canvas
|
|
||||||
Node was later deleted, the orphaned "approved" row must be reset to
|
|
||||||
"pending" on re-import so it shows up in the Pending list again — instead of
|
|
||||||
being silently swallowed (re-import reports "found" but Pending stays empty).
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
# Simulate prior approve: a PendingDevice marked approved, but NO matching
|
|
||||||
# Node exists (the user deleted the canvas node afterwards).
|
|
||||||
orphan = PendingDevice(
|
|
||||||
ieee_address="0xR1",
|
|
||||||
friendly_name="router_1",
|
|
||||||
hostname="router_1",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
model="CC2530",
|
|
||||||
vendor="TI",
|
|
||||||
lqi=220,
|
|
||||||
status="approved",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(orphan)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
# No new row created for 0xR1 — the existing one was updated/revived.
|
|
||||||
revived = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert revived.status == "pending"
|
|
||||||
# Coordinator + end device 0xE1 are brand new → created; router was revived.
|
|
||||||
assert result.pending_created == 2
|
|
||||||
assert result.pending_updated == 1
|
|
||||||
|
|
||||||
# It is now visible to the Pending list (status filter == "pending").
|
|
||||||
listed = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.status == "pending")
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_keeps_hidden_hidden_on_reimport(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""A user-hidden device must stay hidden on re-import (not revived like #167)."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
hidden = PendingDevice(
|
|
||||||
ieee_address="0xR1",
|
|
||||||
friendly_name="router_1",
|
|
||||||
suggested_type="zigbee_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
status="hidden",
|
|
||||||
discovery_source="zigbee",
|
|
||||||
)
|
|
||||||
db_session.add(hidden)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
still_hidden = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert still_hidden.status == "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_preserves_user_visibility(db_session) -> None:
|
|
||||||
"""If user has already made props visible, re-import must not flip them back."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Node
|
|
||||||
|
|
||||||
approved = Node(
|
|
||||||
label="router_1",
|
|
||||||
type="zigbee_router",
|
|
||||||
status="online",
|
|
||||||
check_method="none",
|
|
||||||
ieee_address="0xR1",
|
|
||||||
services=[],
|
|
||||||
properties=[
|
|
||||||
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
|
|
||||||
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
|
|
||||||
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
db_session.add(approved)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["lqi"] = 99
|
|
||||||
bumped[1]["model"] = "CC2530"
|
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
refreshed = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
|
||||||
).scalar_one()
|
|
||||||
by_key = {p["key"]: p for p in refreshed.properties}
|
|
||||||
# Existing keys keep their visibility (True).
|
|
||||||
assert by_key["IEEE"]["visible"] is True
|
|
||||||
assert by_key["Vendor"]["visible"] is True
|
|
||||||
# New key arrives hidden.
|
|
||||||
assert by_key["Model"]["visible"] is False
|
|
||||||
assert by_key["LQI"]["visible"] is False
|
|
||||||
assert by_key["LQI"]["value"] == "99"
|
|
||||||
# Non-zigbee user-added prop is preserved untouched.
|
|
||||||
assert by_key["Custom"]["value"] == "kept"
|
|
||||||
assert by_key["Custom"]["visible"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""An already-approved coordinator Node gets its props refreshed on
|
|
||||||
re-import, and a backfilled inventory row (approved) so it shows in the
|
|
||||||
inventory list — this is the legacy auto-placed coordinator scenario."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
# Legacy: coordinator auto-placed as a canvas Node, no pending_devices row.
|
|
||||||
db_session.add(Node(
|
|
||||||
label="Coordinator", type="zigbee_coordinator", status="online",
|
|
||||||
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[0]["vendor"] = "TI"
|
|
||||||
bumped[0]["model"] = "CC2652"
|
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
coord = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
|
||||||
).scalar_one()
|
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
|
||||||
assert keys["Vendor"] == "TI"
|
|
||||||
assert keys["Model"] == "CC2652"
|
|
||||||
by_key = {p["key"]: p for p in coord.properties}
|
|
||||||
assert by_key["Vendor"]["visible"] is False
|
|
||||||
# Inventory row backfilled as approved → now visible in the inventory list.
|
|
||||||
inv = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert inv.status == "approved"
|
|
||||||
assert inv.suggested_type == "zigbee_coordinator"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_pending_import_device_on_multiple_canvases(
|
|
||||||
db_session,
|
|
||||||
) -> None:
|
|
||||||
"""Regression: a device approved onto TWO designs (one Node each) must not
|
|
||||||
crash re-import with MultipleResultsFound — props refresh on both nodes."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
|
||||||
from app.db.models import Design, Node
|
|
||||||
|
|
||||||
d1 = Design(name="d1")
|
|
||||||
d2 = Design(name="d2")
|
|
||||||
db_session.add_all([d1, d2])
|
|
||||||
await db_session.flush()
|
|
||||||
for d in (d1, d2):
|
|
||||||
db_session.add(Node(
|
|
||||||
label="router_1", type="zigbee_router", status="online",
|
|
||||||
check_method="none", ieee_address="0xR1", services=[],
|
|
||||||
properties=[], design_id=d.id,
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["lqi"] = 240
|
|
||||||
# Must not raise.
|
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
|
|
||||||
).scalars().all()
|
|
||||||
assert len(nodes) == 2 # both canvas placements preserved
|
|
||||||
for n in nodes:
|
|
||||||
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
|
|
||||||
assert lqi == "240" # refreshed on every canvas
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/import-pending",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zigbee.test_mqtt_connection") as mock_conn:
|
|
||||||
mock_conn.return_value = True
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/test-connection",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "broker.example.com",
|
|
||||||
"mqtt_port": 8883,
|
|
||||||
"mqtt_tls": True,
|
|
||||||
"mqtt_tls_insecure": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
kwargs = mock_conn.call_args.kwargs
|
|
||||||
assert kwargs["tls"] is True
|
|
||||||
assert kwargs["tls_insecure"] is True
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zigbee/config + /sync-now (auto-sync)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from app.core.config import settings # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _restore_zigbee_env():
|
|
||||||
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
|
|
||||||
keys = (
|
|
||||||
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
|
|
||||||
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
|
|
||||||
"zigbee_sync_interval",
|
|
||||||
)
|
|
||||||
saved = {k: getattr(settings, k) for k in keys}
|
|
||||||
yield
|
|
||||||
for k, v in saved.items():
|
|
||||||
setattr(settings, k, v)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_config_omits_credentials(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
|
||||||
) -> None:
|
|
||||||
settings.zigbee_mqtt_host = "broker"
|
|
||||||
settings.zigbee_mqtt_username = "user"
|
|
||||||
settings.zigbee_mqtt_password = "supersecret"
|
|
||||||
res = await client.get("/api/v1/zigbee/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert "supersecret" not in res.text
|
|
||||||
assert "user" not in res.text
|
|
||||||
assert res.json()["host_configured"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.get("/api/v1/zigbee/config")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enable_sync_without_host_rejected(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
|
||||||
) -> None:
|
|
||||||
settings.zigbee_mqtt_host = ""
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/config",
|
|
||||||
json={"sync_enabled": True, "sync_interval": 600},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_save_config_persists_only_sync_fields(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
|
||||||
) -> None:
|
|
||||||
settings.zigbee_mqtt_host = "broker"
|
|
||||||
saved: dict = {}
|
|
||||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
|
||||||
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
|
|
||||||
interval=self.zigbee_sync_interval,
|
|
||||||
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
|
|
||||||
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zigbee/config",
|
|
||||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert settings.zigbee_mqtt_host == "broker" # body host ignored
|
|
||||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_creates_scan_run(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
|
||||||
) -> None:
|
|
||||||
settings.zigbee_mqtt_host = "broker"
|
|
||||||
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
|
|
||||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["kind"] == "zigbee"
|
|
||||||
assert data["status"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_rejected_without_host(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zigbee_env
|
|
||||||
) -> None:
|
|
||||||
settings.zigbee_mqtt_host = ""
|
|
||||||
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post("/api/v1/zigbee/sync-now")
|
|
||||||
assert res.status_code == 401
|
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
"""Unit tests for zigbee_service: parser and hierarchy builder."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import aiomqtt # noqa: F401
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.services.zigbee_service import (
|
|
||||||
_find_parent_router,
|
|
||||||
_z2m_type_to_homelable,
|
|
||||||
fetch_networkmap,
|
|
||||||
parse_networkmap,
|
|
||||||
)
|
|
||||||
from app.services.zigbee_service import (
|
|
||||||
test_mqtt_connection as _test_mqtt_connection,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Helper builders — real Z2M `bridge/response/networkmap` shape
|
|
||||||
# (data.value.nodes + data.value.links)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _make_node(
|
|
||||||
ieee: str,
|
|
||||||
device_type: str = "EndDevice",
|
|
||||||
friendly_name: str | None = None,
|
|
||||||
model: str | None = None,
|
|
||||||
vendor: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
entry: dict[str, Any] = {
|
|
||||||
"ieeeAddr": ieee,
|
|
||||||
"type": device_type,
|
|
||||||
"friendlyName": friendly_name or ieee,
|
|
||||||
}
|
|
||||||
if model or vendor:
|
|
||||||
entry["definition"] = {"model": model, "vendor": vendor}
|
|
||||||
return entry
|
|
||||||
|
|
||||||
|
|
||||||
def _make_link(source_ieee: str, target_ieee: str, lqi: int = 200) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"source": {"ieeeAddr": source_ieee},
|
|
||||||
"target": {"ieeeAddr": target_ieee},
|
|
||||||
"lqi": lqi,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _wrap(nodes: list[dict[str, Any]], links: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"data": {
|
|
||||||
"type": "raw",
|
|
||||||
"routes": False,
|
|
||||||
"value": {"nodes": nodes, "links": links or []},
|
|
||||||
},
|
|
||||||
"status": "ok",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _z2m_type_to_homelable
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestZ2mTypeToHomelable:
|
|
||||||
def test_coordinator(self) -> None:
|
|
||||||
assert _z2m_type_to_homelable("Coordinator") == "zigbee_coordinator"
|
|
||||||
|
|
||||||
def test_router(self) -> None:
|
|
||||||
assert _z2m_type_to_homelable("Router") == "zigbee_router"
|
|
||||||
|
|
||||||
def test_enddevice(self) -> None:
|
|
||||||
assert _z2m_type_to_homelable("EndDevice") == "zigbee_enddevice"
|
|
||||||
|
|
||||||
def test_unknown_defaults_to_enddevice(self) -> None:
|
|
||||||
assert _z2m_type_to_homelable("Unknown") == "zigbee_enddevice"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# parse_networkmap
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestParseNetworkmap:
|
|
||||||
def test_empty_payload(self) -> None:
|
|
||||||
nodes, edges = parse_networkmap({})
|
|
||||||
assert nodes == []
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_empty_value(self) -> None:
|
|
||||||
nodes, edges = parse_networkmap(_wrap([], []))
|
|
||||||
assert nodes == []
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_coordinator_only(self) -> None:
|
|
||||||
payload = _wrap([_make_node("0x0000000000000000", "Coordinator", "Coordinator")])
|
|
||||||
nodes, edges = parse_networkmap(payload)
|
|
||||||
assert len(nodes) == 1
|
|
||||||
assert nodes[0]["type"] == "zigbee_coordinator"
|
|
||||||
assert nodes[0]["ieee_address"] == "0x0000000000000000"
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_coordinator_router_enddevice(self) -> None:
|
|
||||||
coord_ieee = "0x0000000000000000"
|
|
||||||
router_ieee = "0x0000000000000001"
|
|
||||||
end_ieee = "0x0000000000000002"
|
|
||||||
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[
|
|
||||||
_make_node(coord_ieee, "Coordinator", "Coordinator"),
|
|
||||||
_make_node(router_ieee, "Router", "my_router"),
|
|
||||||
_make_node(end_ieee, "EndDevice"),
|
|
||||||
],
|
|
||||||
links=[
|
|
||||||
_make_link(coord_ieee, router_ieee),
|
|
||||||
_make_link(router_ieee, end_ieee),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
nodes, edges = parse_networkmap(payload)
|
|
||||||
node_by_id = {n["id"]: n for n in nodes}
|
|
||||||
|
|
||||||
assert coord_ieee in node_by_id
|
|
||||||
assert router_ieee in node_by_id
|
|
||||||
assert end_ieee in node_by_id
|
|
||||||
|
|
||||||
assert node_by_id[coord_ieee]["type"] == "zigbee_coordinator"
|
|
||||||
assert node_by_id[router_ieee]["type"] == "zigbee_router"
|
|
||||||
assert node_by_id[end_ieee]["type"] == "zigbee_enddevice"
|
|
||||||
|
|
||||||
# Parent hierarchy
|
|
||||||
assert node_by_id[router_ieee]["parent_id"] == coord_ieee
|
|
||||||
assert node_by_id[end_ieee]["parent_id"] == router_ieee
|
|
||||||
assert len(edges) == 2
|
|
||||||
|
|
||||||
def test_no_duplicate_nodes(self) -> None:
|
|
||||||
ieee = "0x0000000000000001"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node(ieee, "Router"), _make_node(ieee, "Router")],
|
|
||||||
)
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
assert len(nodes) == 1
|
|
||||||
|
|
||||||
def test_edges_built_correctly(self) -> None:
|
|
||||||
coord = "0x0000"
|
|
||||||
router = "0x0001"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
|
||||||
links=[_make_link(coord, router)],
|
|
||||||
)
|
|
||||||
_, edges = parse_networkmap(payload)
|
|
||||||
assert len(edges) == 1
|
|
||||||
assert edges[0]["source"] == coord
|
|
||||||
assert edges[0]["target"] == router
|
|
||||||
|
|
||||||
def test_friendly_name_used_as_label(self) -> None:
|
|
||||||
payload = _wrap([_make_node("0xABCD", "EndDevice", "Living Room Sensor")])
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
assert nodes[0]["label"] == "Living Room Sensor"
|
|
||||||
|
|
||||||
def test_enddevice_falls_back_to_coordinator_when_no_router(self) -> None:
|
|
||||||
coord = "0x0000"
|
|
||||||
end = "0x0003"
|
|
||||||
payload = _wrap([_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")])
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
end_node = next(n for n in nodes if n["id"] == end)
|
|
||||||
assert end_node["parent_id"] == coord
|
|
||||||
|
|
||||||
def test_missing_ieee_skipped(self) -> None:
|
|
||||||
payload = _wrap([{"type": "EndDevice"}]) # no ieeeAddr
|
|
||||||
nodes, edges = parse_networkmap(payload)
|
|
||||||
assert nodes == []
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_lqi_propagated_from_link_to_target_node(self) -> None:
|
|
||||||
coord = "0x0000"
|
|
||||||
end = "0x0001"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
|
||||||
links=[_make_link(coord, end, lqi=180)],
|
|
||||||
)
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
end_node = next(n for n in nodes if n["id"] == end)
|
|
||||||
assert end_node["lqi"] == 180
|
|
||||||
|
|
||||||
def test_definition_model_and_vendor_extracted(self) -> None:
|
|
||||||
payload = _wrap([
|
|
||||||
_make_node("0xAA", "EndDevice", "Sensor", model="WSDCGQ11LM", vendor="Aqara"),
|
|
||||||
])
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
assert nodes[0]["model"] == "WSDCGQ11LM"
|
|
||||||
assert nodes[0]["vendor"] == "Aqara"
|
|
||||||
|
|
||||||
def test_legacy_shape_without_value_wrapper(self) -> None:
|
|
||||||
"""Some Z2M variants put nodes/links directly under data."""
|
|
||||||
payload = {"data": {"nodes": [_make_node("0x01", "Coordinator")], "links": []}}
|
|
||||||
nodes, _ = parse_networkmap(payload)
|
|
||||||
assert len(nodes) == 1
|
|
||||||
assert nodes[0]["type"] == "zigbee_coordinator"
|
|
||||||
|
|
||||||
def test_routes_bool_is_ignored(self) -> None:
|
|
||||||
"""`routes: false` echo from the request must not crash the parser."""
|
|
||||||
payload = {"data": {"routes": False, "type": "raw", "value": {"nodes": [], "links": []}}}
|
|
||||||
nodes, edges = parse_networkmap(payload)
|
|
||||||
assert nodes == []
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_malformed_nodes_not_list_raises(self) -> None:
|
|
||||||
with pytest.raises(ValueError, match="not a list"):
|
|
||||||
parse_networkmap({"data": {"value": {"nodes": "oops", "links": []}}})
|
|
||||||
|
|
||||||
def test_link_to_unknown_node_dropped(self) -> None:
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node("0x01", "Coordinator")],
|
|
||||||
links=[_make_link("0x01", "0xDEAD")], # 0xDEAD not in nodes
|
|
||||||
)
|
|
||||||
_, edges = parse_networkmap(payload)
|
|
||||||
assert edges == []
|
|
||||||
|
|
||||||
def test_bidirectional_links_yield_single_edge(self) -> None:
|
|
||||||
"""Z2M links are bidirectional — every pair appears twice. The output
|
|
||||||
must collapse to a single parent→child edge (no back-link, no dup)."""
|
|
||||||
coord = "0x0000"
|
|
||||||
router = "0x0001"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node(coord, "Coordinator"), _make_node(router, "Router")],
|
|
||||||
links=[
|
|
||||||
_make_link(coord, router),
|
|
||||||
_make_link(router, coord), # reverse direction
|
|
||||||
],
|
|
||||||
)
|
|
||||||
_, edges = parse_networkmap(payload)
|
|
||||||
assert edges == [{"source": coord, "target": router}]
|
|
||||||
|
|
||||||
def test_router_mesh_siblings_dropped(self) -> None:
|
|
||||||
"""Router↔router mesh paths in `links` must NOT produce sibling edges
|
|
||||||
in the final tree. Each router gets exactly one edge from coordinator."""
|
|
||||||
coord = "0x0000"
|
|
||||||
r1 = "0x0001"
|
|
||||||
r2 = "0x0002"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[
|
|
||||||
_make_node(coord, "Coordinator"),
|
|
||||||
_make_node(r1, "Router"),
|
|
||||||
_make_node(r2, "Router"),
|
|
||||||
],
|
|
||||||
links=[
|
|
||||||
_make_link(coord, r1),
|
|
||||||
_make_link(coord, r2),
|
|
||||||
_make_link(r1, r2), # mesh sibling — must be dropped
|
|
||||||
_make_link(r2, r1),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
_, edges = parse_networkmap(payload)
|
|
||||||
pairs = {(e["source"], e["target"]) for e in edges}
|
|
||||||
assert pairs == {(coord, r1), (coord, r2)}
|
|
||||||
|
|
||||||
def test_coordinator_has_no_incoming_edge(self) -> None:
|
|
||||||
coord = "0x0000"
|
|
||||||
end = "0x0001"
|
|
||||||
payload = _wrap(
|
|
||||||
nodes=[_make_node(coord, "Coordinator"), _make_node(end, "EndDevice")],
|
|
||||||
links=[_make_link(end, coord)], # back-edge from end to coord
|
|
||||||
)
|
|
||||||
_, edges = parse_networkmap(payload)
|
|
||||||
# No edge should target the coordinator
|
|
||||||
assert all(e["target"] != coord for e in edges)
|
|
||||||
assert edges == [{"source": coord, "target": end}]
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# _find_parent_router
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class TestFindParentRouter:
|
|
||||||
def test_finds_router_as_source(self) -> None:
|
|
||||||
router_ids = {"r1"}
|
|
||||||
edges = [{"source": "r1", "target": "e1"}]
|
|
||||||
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
|
||||||
|
|
||||||
def test_finds_router_as_target(self) -> None:
|
|
||||||
router_ids = {"r1"}
|
|
||||||
edges = [{"source": "e1", "target": "r1"}]
|
|
||||||
assert _find_parent_router("e1", router_ids, edges) == "r1"
|
|
||||||
|
|
||||||
def test_returns_none_when_no_router(self) -> None:
|
|
||||||
router_ids: set[str] = set()
|
|
||||||
edges = [{"source": "e1", "target": "e2"}]
|
|
||||||
assert _find_parent_router("e1", router_ids, edges) is None
|
|
||||||
|
|
||||||
def test_returns_none_empty_edges(self) -> None:
|
|
||||||
assert _find_parent_router("e1", {"r1"}, []) is None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# fetch_networkmap (integration-style with mocked aiomqtt)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SAMPLE_RESPONSE_PAYLOAD = {
|
|
||||||
"data": {
|
|
||||||
"type": "raw",
|
|
||||||
"routes": False,
|
|
||||||
"value": {
|
|
||||||
"nodes": [
|
|
||||||
{
|
|
||||||
"ieeeAddr": "0x00000000",
|
|
||||||
"type": "Coordinator",
|
|
||||||
"friendlyName": "Coordinator",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"ieeeAddr": "0x00000001",
|
|
||||||
"type": "Router",
|
|
||||||
"friendlyName": "router_1",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"links": [
|
|
||||||
{
|
|
||||||
"source": {"ieeeAddr": "0x00000000"},
|
|
||||||
"target": {"ieeeAddr": "0x00000001"},
|
|
||||||
"lqi": 230,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"status": "ok",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_networkmap_success() -> None:
|
|
||||||
"""fetch_networkmap returns parsed nodes/edges when MQTT responds normally."""
|
|
||||||
|
|
||||||
class _FakeMessage:
|
|
||||||
topic = "zigbee2mqtt/bridge/response/networkmap"
|
|
||||||
payload = json.dumps(SAMPLE_RESPONSE_PAYLOAD).encode()
|
|
||||||
_yielded = False
|
|
||||||
|
|
||||||
def __aiter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __anext__(self):
|
|
||||||
if self._yielded:
|
|
||||||
raise StopAsyncIteration
|
|
||||||
self._yielded = True
|
|
||||||
return self
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def subscribe(self, _topic: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def publish(self, _topic: str, _payload: str) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@property
|
|
||||||
def messages(self):
|
|
||||||
return _FakeMessage()
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
nodes, edges = await fetch_networkmap(
|
|
||||||
mqtt_host="localhost",
|
|
||||||
mqtt_port=1883,
|
|
||||||
base_topic="zigbee2mqtt",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert any(n["type"] == "zigbee_coordinator" for n in nodes)
|
|
||||||
assert any(n["type"] == "zigbee_router" for n in nodes)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_networkmap_connection_error() -> None:
|
|
||||||
"""fetch_networkmap raises ConnectionError when MQTT broker is unreachable."""
|
|
||||||
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
raise Exception("Connection refused")
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
with pytest.raises(ConnectionError):
|
|
||||||
await fetch_networkmap(
|
|
||||||
mqtt_host="bad-host",
|
|
||||||
mqtt_port=1883,
|
|
||||||
base_topic="zigbee2mqtt",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_mqtt_connection_success() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
result = await _test_mqtt_connection("localhost", 1883)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_mqtt_connection_failure() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
raise Exception("refused")
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
with pytest.raises(ConnectionError):
|
|
||||||
await _test_mqtt_connection("bad-host", 1883)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# TLS context
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import ssl # noqa: E402
|
|
||||||
|
|
||||||
from app.services.zigbee_service import _build_tls_context # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_tls_context_secure_verifies_cert() -> None:
|
|
||||||
ctx = _build_tls_context(insecure=False)
|
|
||||||
assert ctx.check_hostname is True
|
|
||||||
assert ctx.verify_mode == ssl.CERT_REQUIRED
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_tls_context_insecure_disables_verification() -> None:
|
|
||||||
ctx = _build_tls_context(insecure=True)
|
|
||||||
assert ctx.check_hostname is False
|
|
||||||
assert ctx.verify_mode == ssl.CERT_NONE
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_mqtt_connection_passes_tls_context() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
await _test_mqtt_connection("host", 8883, tls=True)
|
|
||||||
kwargs = mock_aiomqtt.Client.call_args.kwargs
|
|
||||||
assert kwargs["tls_context"] is not None
|
|
||||||
assert kwargs["tls_context"].verify_mode == ssl.CERT_REQUIRED
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_mqtt_connection_no_tls_context_when_disabled() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
await _test_mqtt_connection("host", 1883, tls=False)
|
|
||||||
assert mock_aiomqtt.Client.call_args.kwargs["tls_context"] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_mqtt_connection_insecure_passes_no_verify_context() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
await _test_mqtt_connection("host", 8883, tls=True, tls_insecure=True)
|
|
||||||
ctx = mock_aiomqtt.Client.call_args.kwargs["tls_context"]
|
|
||||||
assert ctx.verify_mode == ssl.CERT_NONE
|
|
||||||
assert ctx.check_hostname is False
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Sanitize MQTT errors
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from app.services.zigbee_service import _sanitize_mqtt_error # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_auth_error_does_not_leak_credentials() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(
|
|
||||||
Exception("Not authorized: bad username or password for user=admin pwd=secret")
|
|
||||||
)
|
|
||||||
assert msg == "Authentication failed"
|
|
||||||
assert "admin" not in msg
|
|
||||||
assert "secret" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_refused() -> None:
|
|
||||||
assert _sanitize_mqtt_error(Exception("Connection refused")) == "Connection refused by broker"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_dns_failure_strips_host() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(
|
|
||||||
Exception("[Errno 8] nodename nor servname provided, or not known: broker.internal.lan")
|
|
||||||
)
|
|
||||||
assert msg == "Broker hostname could not be resolved"
|
|
||||||
assert "broker.internal.lan" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_tls_error() -> None:
|
|
||||||
assert _sanitize_mqtt_error(
|
|
||||||
Exception("[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed")
|
|
||||||
) == "TLS handshake failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_unknown_falls_back_to_generic() -> None:
|
|
||||||
msg = _sanitize_mqtt_error(Exception("mqtt://admin:hunter2@broker:1883 weird state"))
|
|
||||||
assert msg == "MQTT connection failed"
|
|
||||||
assert "hunter2" not in msg
|
|
||||||
assert "admin" not in msg
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fetch_networkmap_does_not_leak_creds_in_connection_error() -> None:
|
|
||||||
class _FakeClient:
|
|
||||||
async def __aenter__(self):
|
|
||||||
raise Exception("Not authorized: rejected mqtt://admin:hunter2@host")
|
|
||||||
|
|
||||||
async def __aexit__(self, *_):
|
|
||||||
pass
|
|
||||||
|
|
||||||
with patch("app.services.zigbee_service.aiomqtt") as mock_aiomqtt:
|
|
||||||
mock_aiomqtt.Client.return_value = _FakeClient()
|
|
||||||
mock_aiomqtt.MqttError = Exception
|
|
||||||
|
|
||||||
with pytest.raises(ConnectionError) as ei:
|
|
||||||
await fetch_networkmap(
|
|
||||||
mqtt_host="host", mqtt_port=1883, base_topic="zigbee2mqtt"
|
|
||||||
)
|
|
||||||
msg = str(ei.value)
|
|
||||||
assert "hunter2" not in msg
|
|
||||||
assert "admin" not in msg
|
|
||||||
assert msg == "Authentication failed"
|
|
||||||
@@ -1,599 +0,0 @@
|
|||||||
"""API endpoint tests for /api/v1/zwave/*."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from httpx import AsyncClient
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zwave/test-connection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_success(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
|
||||||
mock_conn.return_value = True
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["connected"] is True
|
|
||||||
assert "success" in data["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_failure(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.test_zwave_connection") as mock_conn:
|
|
||||||
mock_conn.side_effect = ConnectionError("Connection refused")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/test-connection",
|
|
||||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["connected"] is False
|
|
||||||
assert "refused" in data["message"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_test_connection_invalid_port(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/test-connection",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 99999},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zwave/import
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_SAMPLE_NODES = [
|
|
||||||
{
|
|
||||||
"id": "zwave-0xh-1",
|
|
||||||
"label": "Controller",
|
|
||||||
"type": "zwave_coordinator",
|
|
||||||
"ieee_address": "zwave-0xh-1",
|
|
||||||
"friendly_name": "Controller",
|
|
||||||
"device_type": "Controller",
|
|
||||||
"model": None,
|
|
||||||
"vendor": None,
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "zwave-0xh-2",
|
|
||||||
"label": "Wall Plug",
|
|
||||||
"type": "zwave_router",
|
|
||||||
"ieee_address": "zwave-0xh-2",
|
|
||||||
"friendly_name": "Wall Plug",
|
|
||||||
"device_type": "Router",
|
|
||||||
"model": "ZW100",
|
|
||||||
"vendor": "Aeotec",
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": "zwave-0xh-1",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
_SAMPLE_EDGES = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_success(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.return_value = (_SAMPLE_NODES, _SAMPLE_EDGES)
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["device_count"] == 2
|
|
||||||
assert len(data["edges"]) == 1
|
|
||||||
coordinator = next(n for n in data["nodes"] if n["type"] == "zwave_coordinator")
|
|
||||||
assert coordinator["ieee_address"] == "zwave-0xh-1"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_passes_gateway_and_prefix(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.return_value = ([], [])
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "localhost",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"prefix": "myzwave",
|
|
||||||
"gateway_name": "gw1",
|
|
||||||
"mqtt_username": "admin",
|
|
||||||
"mqtt_password": "secret",
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
mock_fetch.assert_called_once_with(
|
|
||||||
mqtt_host="localhost",
|
|
||||||
mqtt_port=1883,
|
|
||||||
prefix="myzwave",
|
|
||||||
gateway_name="gw1",
|
|
||||||
username="admin",
|
|
||||||
password="secret",
|
|
||||||
tls=False,
|
|
||||||
tls_insecure=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_connection_error_returns_502(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = ConnectionError("broker unreachable")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "bad-host", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 502
|
|
||||||
assert "broker unreachable" in res.json()["detail"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_timeout_returns_504(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = TimeoutError("timed out")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 504
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_malformed_payload_returns_422(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = ValueError("malformed response")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_unexpected_returns_500(client: AsyncClient, headers: dict) -> None:
|
|
||||||
with patch("app.api.routes.zwave.fetch_zwave_network") as mock_fetch:
|
|
||||||
mock_fetch.side_effect = RuntimeError("boom")
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 500
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_tls_insecure_requires_tls(client: AsyncClient, headers: dict) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import",
|
|
||||||
json={
|
|
||||||
"mqtt_host": "broker.example.com",
|
|
||||||
"mqtt_port": 1883,
|
|
||||||
"mqtt_tls": False,
|
|
||||||
"mqtt_tls_insecure": True,
|
|
||||||
},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zwave/import-pending
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_PENDING_NODES = [
|
|
||||||
{
|
|
||||||
"id": "zwave-0xh-1",
|
|
||||||
"label": "Controller",
|
|
||||||
"type": "zwave_coordinator",
|
|
||||||
"ieee_address": "zwave-0xh-1",
|
|
||||||
"friendly_name": "Controller",
|
|
||||||
"device_type": "Controller",
|
|
||||||
"model": None,
|
|
||||||
"vendor": None,
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": None,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "zwave-0xh-2",
|
|
||||||
"label": "Wall Plug",
|
|
||||||
"type": "zwave_router",
|
|
||||||
"ieee_address": "zwave-0xh-2",
|
|
||||||
"friendly_name": "Wall Plug",
|
|
||||||
"device_type": "Router",
|
|
||||||
"model": "ZW100",
|
|
||||||
"vendor": "Aeotec",
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": "zwave-0xh-1",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "zwave-0xh-3",
|
|
||||||
"label": "Door Sensor",
|
|
||||||
"type": "zwave_enddevice",
|
|
||||||
"ieee_address": "zwave-0xh-3",
|
|
||||||
"friendly_name": "Door Sensor",
|
|
||||||
"device_type": "EndDevice",
|
|
||||||
"model": "ZW120",
|
|
||||||
"vendor": "Aeotec",
|
|
||||||
"lqi": None,
|
|
||||||
"parent_id": "zwave-0xh-2",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
_PENDING_EDGES = [
|
|
||||||
{"source": "zwave-0xh-1", "target": "zwave-0xh-2"},
|
|
||||||
{"source": "zwave-0xh-2", "target": "zwave-0xh-3"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_pending_creates_zwave_scan_run(client: AsyncClient, headers: dict) -> None:
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import-pending",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
run = res.json()
|
|
||||||
assert run["kind"] == "zwave"
|
|
||||||
assert run["status"] == "running"
|
|
||||||
assert run["ranges"] == ["localhost:1883"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/import-pending",
|
|
||||||
json={"mqtt_host": "localhost", "mqtt_port": 1883},
|
|
||||||
)
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_coordinator_goes_to_pending(db_session) -> None:
|
|
||||||
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
assert result.device_count == 3
|
|
||||||
assert result.pending_created == 3 # coordinator included now
|
|
||||||
assert result.pending_updated == 0
|
|
||||||
assert result.coordinator is None # not auto-placed
|
|
||||||
assert result.coordinator_already_existed is False
|
|
||||||
assert result.links_recorded == 2
|
|
||||||
|
|
||||||
nodes = (await db_session.execute(select(Node))).scalars().all()
|
|
||||||
assert nodes == []
|
|
||||||
coord = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert coord.status == "pending"
|
|
||||||
assert coord.suggested_type == "zwave_coordinator"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["model"] = "ZW111"
|
|
||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
assert result.pending_created == 0
|
|
||||||
assert result.pending_updated == 3 # coordinator upserts too
|
|
||||||
assert result.coordinator_already_existed is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_replaces_links(db_session) -> None:
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import PendingDeviceLink
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
new_edges = [{"source": "zwave-0xh-1", "target": "zwave-0xh-2"}]
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES[:2], new_edges)
|
|
||||||
rows = (await db_session.execute(select(PendingDeviceLink))).scalars().all()
|
|
||||||
assert len(rows) == 1
|
|
||||||
assert (rows[0].source_ieee, rows[0].target_ieee) == ("zwave-0xh-1", "zwave-0xh-2")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_sets_coordinator_pending_fields(db_session) -> None:
|
|
||||||
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
nodes = [dict(n) for n in _PENDING_NODES]
|
|
||||||
nodes[0]["vendor"] = "Aeotec"
|
|
||||||
nodes[0]["model"] = "ZW090"
|
|
||||||
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
|
||||||
coord = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert coord.vendor == "Aeotec"
|
|
||||||
assert coord.model == "ZW090"
|
|
||||||
assert coord.suggested_type == "zwave_coordinator"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_backfills_inventory_for_approved_node(db_session) -> None:
|
|
||||||
"""On-canvas device missing its inventory row gets one backfilled
|
|
||||||
(status="approved"); Node props still refresh."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import Node, PendingDevice
|
|
||||||
|
|
||||||
approved = Node(
|
|
||||||
label="Wall Plug",
|
|
||||||
type="zwave_router",
|
|
||||||
status="online",
|
|
||||||
check_method="none",
|
|
||||||
ieee_address="zwave-0xh-2",
|
|
||||||
services=[],
|
|
||||||
properties=[],
|
|
||||||
)
|
|
||||||
db_session.add(approved)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
|
|
||||||
inv = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert inv.status == "approved"
|
|
||||||
assert inv.suggested_type == "zwave_router"
|
|
||||||
refreshed = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
|
||||||
).scalar_one()
|
|
||||||
keys = {p["key"]: p["value"] for p in refreshed.properties}
|
|
||||||
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
orphan = PendingDevice(
|
|
||||||
ieee_address="zwave-0xh-2",
|
|
||||||
friendly_name="Wall Plug",
|
|
||||||
suggested_type="zwave_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
status="approved",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
db_session.add(orphan)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
revived = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert revived.status == "pending"
|
|
||||||
# Coordinator + end device are brand new → created; router was revived.
|
|
||||||
assert result.pending_created == 2
|
|
||||||
assert result.pending_updated == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import PendingDevice
|
|
||||||
|
|
||||||
hidden = PendingDevice(
|
|
||||||
ieee_address="zwave-0xh-2",
|
|
||||||
friendly_name="Wall Plug",
|
|
||||||
suggested_type="zwave_router",
|
|
||||||
device_subtype="Router",
|
|
||||||
status="hidden",
|
|
||||||
discovery_source="zwave",
|
|
||||||
)
|
|
||||||
db_session.add(hidden)
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
|
||||||
still_hidden = (
|
|
||||||
await db_session.execute(
|
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
|
||||||
)
|
|
||||||
).scalar_one()
|
|
||||||
assert still_hidden.status == "hidden"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_persist_device_on_multiple_canvases(db_session) -> None:
|
|
||||||
"""Regression: a device on TWO designs (one Node each) must not crash
|
|
||||||
re-import with MultipleResultsFound — props refresh on both nodes."""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
|
||||||
from app.db.models import Design, Node
|
|
||||||
|
|
||||||
d1 = Design(name="d1")
|
|
||||||
d2 = Design(name="d2")
|
|
||||||
db_session.add_all([d1, d2])
|
|
||||||
await db_session.flush()
|
|
||||||
for d in (d1, d2):
|
|
||||||
db_session.add(Node(
|
|
||||||
label="Wall Plug", type="zwave_router", status="online",
|
|
||||||
check_method="none", ieee_address="zwave-0xh-2", services=[],
|
|
||||||
properties=[], design_id=d.id,
|
|
||||||
))
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
bumped = [dict(n) for n in _PENDING_NODES]
|
|
||||||
bumped[1]["model"] = "ZW200"
|
|
||||||
# Must not raise.
|
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
|
||||||
|
|
||||||
nodes = (
|
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
|
||||||
).scalars().all()
|
|
||||||
assert len(nodes) == 2 # both canvas placements preserved
|
|
||||||
for n in nodes:
|
|
||||||
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
|
|
||||||
assert model == "ZW200" # refreshed on every canvas
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# /api/v1/zwave/config + /sync-now (auto-sync)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
from unittest.mock import AsyncMock # noqa: E402
|
|
||||||
|
|
||||||
from app.core.config import settings # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def _restore_zwave_env():
|
|
||||||
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
|
|
||||||
keys = (
|
|
||||||
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
|
|
||||||
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
|
|
||||||
"zwave_sync_enabled", "zwave_sync_interval",
|
|
||||||
)
|
|
||||||
saved = {k: getattr(settings, k) for k in keys}
|
|
||||||
yield
|
|
||||||
for k, v in saved.items():
|
|
||||||
setattr(settings, k, v)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_config_omits_credentials(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
|
||||||
) -> None:
|
|
||||||
settings.zwave_mqtt_host = "broker"
|
|
||||||
settings.zwave_mqtt_username = "user"
|
|
||||||
settings.zwave_mqtt_password = "supersecret"
|
|
||||||
res = await client.get("/api/v1/zwave/config", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert "supersecret" not in res.text
|
|
||||||
assert "user" not in res.text
|
|
||||||
assert res.json()["host_configured"] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_config_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.get("/api/v1/zwave/config")
|
|
||||||
assert res.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_enable_sync_without_host_rejected(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
|
||||||
) -> None:
|
|
||||||
settings.zwave_mqtt_host = ""
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/config",
|
|
||||||
json={"sync_enabled": True, "sync_interval": 600},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_save_config_persists_only_sync_fields(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
|
||||||
) -> None:
|
|
||||||
settings.zwave_mqtt_host = "broker"
|
|
||||||
saved: dict = {}
|
|
||||||
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
|
|
||||||
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
|
|
||||||
interval=self.zwave_sync_interval,
|
|
||||||
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
|
|
||||||
patch("app.api.routes.zwave.reschedule_zwave_sync"):
|
|
||||||
res = await client.post(
|
|
||||||
"/api/v1/zwave/config",
|
|
||||||
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
|
|
||||||
headers=headers,
|
|
||||||
)
|
|
||||||
assert res.status_code == 200
|
|
||||||
assert settings.zwave_mqtt_host == "broker" # body host ignored
|
|
||||||
assert saved == {"host": "broker", "enabled": True, "interval": 900}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_creates_scan_run(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
|
||||||
) -> None:
|
|
||||||
settings.zwave_mqtt_host = "broker"
|
|
||||||
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
|
|
||||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 200
|
|
||||||
data = res.json()
|
|
||||||
assert data["kind"] == "zwave"
|
|
||||||
assert data["status"] == "running"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_rejected_without_host(
|
|
||||||
client: AsyncClient, headers: dict, _restore_zwave_env
|
|
||||||
) -> None:
|
|
||||||
settings.zwave_mqtt_host = ""
|
|
||||||
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
|
|
||||||
assert res.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
|
|
||||||
res = await client.post("/api/v1/zwave/sync-now")
|
|
||||||
assert res.status_code == 401
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user