Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b8b9760bc | |||
| f1fe82646d | |||
| b7e03142e9 | |||
| af3dc018c7 | |||
| 79bd0d4efb | |||
| a364a95cb5 | |||
| 5b0c064e68 | |||
| 6f01d5550d | |||
| 9000f82213 | |||
| 587f7f3e61 | |||
| 2f91ca3dfd | |||
| 93e9b68821 | |||
| d63c9e6451 | |||
| 71ab3bf391 | |||
| 9c0ed32485 | |||
| 8d752e0bd8 | |||
| 310b9cb3fd | |||
| fceefe6d4d | |||
| ba4f8b0be4 | |||
| a7b7327ce5 | |||
| 6ae8b91934 | |||
| 3cab115a80 | |||
| 9e088ca7ac | |||
| 6cc8399dca | |||
| 4956e8ab90 | |||
| d5cbdce870 | |||
| 15b7ce4481 | |||
| 8df7f682e0 | |||
| 30619caf0f | |||
| 47e144ac1f | |||
| 5fada99e25 | |||
| 2e4b353fcd | |||
| d0e5c105ec | |||
| 9a03097f80 | |||
| 228f2a67e4 | |||
| 9331bc43cb | |||
| f98a55dca0 | |||
| 5139daef90 | |||
| d3728f4108 | |||
| b99450db2f | |||
| a90ca2f039 | |||
| ad4aa4aba4 | |||
| c4cb44709d | |||
| 24e755a097 | |||
| 075f7f6d78 | |||
| 4124d6c5a4 | |||
| 64c48de1d1 | |||
| 012ba9cb97 | |||
| 6644a208bb | |||
| 54647413c4 | |||
| 93a50cf144 | |||
| b7985306cd | |||
| 27e18f1c96 | |||
| 2d00be71bb | |||
| 6e3d45fddc | |||
| fd3a3f1da6 | |||
| 9205f9d36e | |||
| 991169ba5a | |||
| b513fa6f4e | |||
| 3384a05932 | |||
| 9051f6ca3e | |||
| d9c6a4ea31 | |||
| 160690bad8 | |||
| 1aecb0a4d9 | |||
| 15b000d555 | |||
| 2d3b646e45 | |||
| 2bebe8f42d | |||
| 0792105b96 | |||
| 8ebc716de8 | |||
| b09ebb5cd2 | |||
| 9437a74147 | |||
| 4206d50c70 | |||
| 0b4bd5680d | |||
| 6b591cdd88 | |||
| 7db57bba3b | |||
| b907c4b05e | |||
| 2d376c2bed | |||
| 738595be5a | |||
| 7bc3e5d8a0 | |||
| 65a1f49f93 | |||
| 5b5eabf5db | |||
| 601f731135 | |||
| 3d25fcaae2 | |||
| adf82f8f01 | |||
| 05ef746f22 | |||
| 9670d0a86a | |||
| abefc42fdf | |||
| ab36ba6f81 | |||
| 1d40d70150 | |||
| 485d2f2b04 | |||
| 40ec26ab7e | |||
| c95d104245 | |||
| 1cf525844b | |||
| ae2d3e1eab | |||
| e8530ad3db | |||
| e39ab7d530 | |||
| 738eac3ebb | |||
| 1479c777d0 | |||
| 6302c43e06 | |||
| 63e664efdd | |||
| b708a08fd0 | |||
| c9a35b730d | |||
| d573cffe0e | |||
| b59476f2f9 | |||
| 9b40ba0b7c | |||
| 6f82c15c69 | |||
| 1de6e91ba3 | |||
| 3f6e9b00f7 | |||
| 60383bee64 | |||
| b0cf0deab0 | |||
| bdde03cdfa | |||
| 6160090919 | |||
| 1ed013bde2 | |||
| 046c99e219 | |||
| 8821b05b7b | |||
| c0c42d5f46 |
@@ -1,6 +1,9 @@
|
|||||||
# 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.
|
# 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"]
|
||||||
|
|
||||||
@@ -43,3 +46,38 @@ MCP_SERVICE_KEY=svc_changeme
|
|||||||
# the same value in the `X-API-Key` header.
|
# the same value in the `X-API-Key` header.
|
||||||
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
# HOMEPAGE_API_KEY=
|
# HOMEPAGE_API_KEY=
|
||||||
|
|
||||||
|
# Proxmox VE import — pull hosts/VMs/LXC from the Proxmox REST API.
|
||||||
|
# The token is a credential: kept in memory only, never written to disk by the
|
||||||
|
# app, never returned by any API. Create it under Datacenter → Permissions →
|
||||||
|
# API Tokens and grant the read-only PVEAuditor role at path "/".
|
||||||
|
# Token id format: user@realm!tokenname (e.g. root@pam!homelable).
|
||||||
|
# Required only for auto-sync; one-off imports can pass the token in the dialog.
|
||||||
|
# PROXMOX_TOKEN_ID=root@pam!homelable
|
||||||
|
# PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
# PROXMOX_HOST=192.168.1.10
|
||||||
|
# PROXMOX_PORT=8006
|
||||||
|
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
|
||||||
|
|
||||||
|
# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule.
|
||||||
|
# MQTT credentials are secrets: kept in memory only, never written to disk by
|
||||||
|
# the app, never returned by any API. Manual imports (the Zigbee dialog) are
|
||||||
|
# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now.
|
||||||
|
# Required only for auto-sync; one-off imports pass their config in the dialog.
|
||||||
|
# ZIGBEE_MQTT_HOST=192.168.1.20
|
||||||
|
# ZIGBEE_MQTT_PORT=1883
|
||||||
|
# ZIGBEE_MQTT_USERNAME=mqttuser # optional
|
||||||
|
# ZIGBEE_MQTT_PASSWORD=mqttpass # optional
|
||||||
|
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
|
||||||
|
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||||
|
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||||
|
|
||||||
|
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
|
||||||
|
# ZWAVE_MQTT_HOST=192.168.1.20
|
||||||
|
# ZWAVE_MQTT_PORT=1883
|
||||||
|
# ZWAVE_MQTT_USERNAME=mqttuser # optional
|
||||||
|
# ZWAVE_MQTT_PASSWORD=mqttpass # optional
|
||||||
|
# ZWAVE_PREFIX=zwave
|
||||||
|
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
|
||||||
|
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
|
||||||
|
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
|
||||||
|
|||||||
+6
-1
@@ -1,6 +1,5 @@
|
|||||||
# Claude / project meta — never commit
|
# Claude / project meta — never commit
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
FEATURES.md
|
|
||||||
.claude/
|
.claude/
|
||||||
_project_specs/
|
_project_specs/
|
||||||
|
|
||||||
@@ -51,3 +50,9 @@ htmlcov/
|
|||||||
# 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
|
||||||
|
|||||||
+636
@@ -0,0 +1,636 @@
|
|||||||
|
# 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.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
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
# 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).*
|
||||||
@@ -8,23 +8,31 @@ You can also select some pre-built design styles, or personalize each device in
|
|||||||
|
|
||||||
If you just like the design, you can only run the frontend and export your design as PNG.
|
If you just like the design, you can only run the frontend and export your design as PNG.
|
||||||
|
|
||||||
If you are running <img width="35" height="35" align="middle" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
If you are running <img width="22" height="22" align="top" alt="New_Home_Assistant_logo" src="https://github.com/user-attachments/assets/3bb17686-c706-40ce-a2d3-57e02378f37c" /> Homeassistant, check the [Homelable HA version](https://github.com/Pouzor/homelable-hacs) (via HACS)
|
||||||
|
|
||||||
|
|
||||||
|
<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="250" height="55"/></a>
|
||||||
---
|
---
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<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 src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
|
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
|
||||||
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
|
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
|
||||||
<img width="48%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/06caab68-6637-4dda-ab16-7e83f63d3972" />
|
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
|
||||||
|
|
||||||
</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)**.
|
||||||
@@ -154,6 +162,37 @@ Hierarchy is set automatically: controller → routers → end devices (`parent_
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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 (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.
|
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.
|
||||||
@@ -278,7 +317,7 @@ docker compose up -d mcp
|
|||||||
|
|
||||||
**Claude Code** — run this command in your terminal:
|
**Claude Code** — run this command in your terminal:
|
||||||
```bash
|
```bash
|
||||||
claude mcp add --transport sse homelable http://<your-homelab-ip>:8001/mcp \
|
claude mcp add --transport http homelable http://<your-homelab-ip>:8001/mcp/ \
|
||||||
--header "X-API-Key: mcp_sk_yourkey"
|
--header "X-API-Key: mcp_sk_yourkey"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -287,8 +326,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
@@ -302,8 +341,8 @@ Or add it manually to `~/.claude.json`:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"homelable": {
|
"homelable": {
|
||||||
"type": "sse",
|
"type": "http",
|
||||||
"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,21 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, 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, Design, Edge, Node
|
||||||
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
|
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
|
||||||
|
|
||||||
router = APIRouter()
|
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])
|
@router.get("", response_model=list[DesignResponse])
|
||||||
async def list_designs(
|
async def list_designs(
|
||||||
@@ -16,7 +23,31 @@ async def list_designs(
|
|||||||
_: str = Depends(get_current_user),
|
_: str = Depends(get_current_user),
|
||||||
) -> list[DesignResponse]:
|
) -> list[DesignResponse]:
|
||||||
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
designs = (await db.execute(select(Design).order_by(Design.created_at))).scalars().all()
|
||||||
return [DesignResponse.model_validate(d) for d in designs]
|
# One grouped query for all designs → node/group/text counts per design.
|
||||||
|
rows = (
|
||||||
|
await db.execute(select(Node.design_id, Node.type, func.count()).group_by(Node.design_id, Node.type))
|
||||||
|
).all()
|
||||||
|
counts: dict[str, dict[str, int]] = {}
|
||||||
|
for design_id, node_type, count in rows:
|
||||||
|
if design_id is None:
|
||||||
|
continue
|
||||||
|
bucket = counts.setdefault(design_id, {"node": 0, "group": 0, "text": 0})
|
||||||
|
if node_type == _GROUP_TYPE:
|
||||||
|
bucket["group"] += count
|
||||||
|
elif node_type == _TEXT_TYPE:
|
||||||
|
bucket["text"] += count
|
||||||
|
else:
|
||||||
|
bucket["node"] += count
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for d in designs:
|
||||||
|
resp = DesignResponse.model_validate(d)
|
||||||
|
c = counts.get(d.id, {"node": 0, "group": 0, "text": 0})
|
||||||
|
resp.node_count = c["node"]
|
||||||
|
resp.group_count = c["group"]
|
||||||
|
resp.text_count = c["text"]
|
||||||
|
result.append(resp)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=DesignResponse, status_code=201)
|
@router.post("", response_model=DesignResponse, status_code=201)
|
||||||
@@ -35,6 +66,73 @@ async def create_design(
|
|||||||
return DesignResponse.model_validate(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)
|
@router.put("/{design_id}", response_model=DesignResponse)
|
||||||
async def update_design(
|
async def update_design(
|
||||||
design_id: str,
|
design_id: str,
|
||||||
|
|||||||
@@ -4,11 +4,56 @@ 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
|
from app.db.models import Design, Edge, Node
|
||||||
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]:
|
||||||
@@ -25,6 +70,18 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s
|
|||||||
if data.get("design_id") is None:
|
if data.get("design_id") is None:
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
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
|
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)
|
edge = Edge(**data)
|
||||||
db.add(edge)
|
db.add(edge)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Generic media upload/serve endpoint.
|
||||||
|
|
||||||
|
Images are stored on disk (see `Settings.media_dir`) with server-generated
|
||||||
|
UUID filenames — never in the DB, and the client filename is never trusted.
|
||||||
|
Upload/delete require auth; GET is public so plain <img> tags and the read-only
|
||||||
|
live view can load images (filenames are unguessable).
|
||||||
|
|
||||||
|
Currently used by the floor-plan feature; kept deliberately generic so future
|
||||||
|
raw-image uploads reuse the same endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# content-type → extension. Also the allowlist of accepted uploads.
|
||||||
|
ALLOWED_TYPES: dict[str, str] = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
|
||||||
|
_MAGIC: dict[str, tuple[bytes, ...]] = {
|
||||||
|
".png": (b"\x89PNG\r\n\x1a\n",),
|
||||||
|
".jpg": (b"\xff\xd8\xff",),
|
||||||
|
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
|
||||||
|
}
|
||||||
|
|
||||||
|
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||||
|
|
||||||
|
# Only ever serve/delete files we created: 32 hex chars + known extension.
|
||||||
|
_NAME_RE = re.compile(r"[0-9a-f]{32}\.(png|jpg|webp)")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_media_path(filename: str) -> Path:
|
||||||
|
"""Return the existing media file named `filename`, or raise 404.
|
||||||
|
|
||||||
|
`filename` is validated against `_NAME_RE` (no separators, no `..`), then
|
||||||
|
matched by name against the actual directory listing. The user string is
|
||||||
|
only ever compared with `==` against trusted `iterdir()` entries — it never
|
||||||
|
builds a path — so a crafted value cannot escape the media dir.
|
||||||
|
"""
|
||||||
|
if not _NAME_RE.fullmatch(filename):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||||
|
base = settings.media_dir()
|
||||||
|
if base.is_dir():
|
||||||
|
for entry in base.iterdir():
|
||||||
|
if entry.name == filename and entry.is_file():
|
||||||
|
return entry
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload")
|
||||||
|
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
|
||||||
|
ext = ALLOWED_TYPES.get(file.content_type or "")
|
||||||
|
if ext is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
detail="Unsupported media type — PNG, JPEG, or WebP only",
|
||||||
|
)
|
||||||
|
# Read one byte past the cap so we can detect oversize without loading more.
|
||||||
|
data = await file.read(MAX_BYTES + 1)
|
||||||
|
if len(data) > MAX_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
||||||
|
detail="File too large (max 10 MB)",
|
||||||
|
)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
|
||||||
|
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
detail="File content does not match its type",
|
||||||
|
)
|
||||||
|
|
||||||
|
media_dir = settings.media_dir()
|
||||||
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
(media_dir / name).write_bytes(data)
|
||||||
|
return {"filename": name, "url": f"/api/v1/media/{name}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{filename}")
|
||||||
|
async def get_media(filename: str) -> FileResponse:
|
||||||
|
# _resolve_media_path returns only an existing file, else raises 404.
|
||||||
|
return FileResponse(_resolve_media_path(filename))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
|
||||||
|
_resolve_media_path(filename).unlink()
|
||||||
@@ -6,9 +6,53 @@ 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 Design, 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]:
|
||||||
@@ -19,6 +63,8 @@ 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()
|
data = body.model_dump()
|
||||||
|
# `force` bypasses the duplicate guard below; it is not a Node column.
|
||||||
|
force = data.pop("force", False)
|
||||||
# Attach to a design so the node lands on a canvas. Clients that don't send a
|
# 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
|
# 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
|
# nodes that exist in the DB but never render in the UI until a container
|
||||||
@@ -26,6 +72,31 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
|
|||||||
if data.get("design_id") is None:
|
if data.get("design_id") is None:
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
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
|
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)
|
node = Node(**data)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
"""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()
|
||||||
+208
-51
@@ -15,14 +15,29 @@ from app.db.database import AsyncSessionLocal, get_db
|
|||||||
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Design, Edge, Node, PendingDevice, PendingDeviceLink, 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 DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
|
||||||
from app.services.zigbee_service import build_zigbee_properties
|
from app.services.zigbee_service import (
|
||||||
|
build_zigbee_properties,
|
||||||
|
merge_zigbee_properties,
|
||||||
|
)
|
||||||
from app.services.zwave_service import build_zwave_properties
|
from app.services.zwave_service import build_zwave_properties
|
||||||
|
|
||||||
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
|
||||||
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_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:
|
def _is_wireless(node_type: str | None) -> bool:
|
||||||
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
|
||||||
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
|
||||||
@@ -211,12 +226,18 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
|
|||||||
async def _canvas_correlation(
|
async def _canvas_correlation(
|
||||||
db: AsyncSession, devices: list[PendingDevice]
|
db: AsyncSession, devices: list[PendingDevice]
|
||||||
) -> dict[str, dict[str, Any]]:
|
) -> dict[str, dict[str, Any]]:
|
||||||
"""Correlate each device to existing canvas nodes by ``ieee_address`` or ``ip``.
|
"""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
|
Returns, per device id: the number of distinct canvases (designs) it appears
|
||||||
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
on, plus aggregated timestamps from every matching node — created_at (oldest),
|
||||||
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
|
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.
|
(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:
|
if not devices:
|
||||||
return {}
|
return {}
|
||||||
@@ -224,6 +245,7 @@ async def _canvas_correlation(
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
select(
|
select(
|
||||||
Node.ip,
|
Node.ip,
|
||||||
|
Node.mac,
|
||||||
Node.ieee_address,
|
Node.ieee_address,
|
||||||
Node.design_id,
|
Node.design_id,
|
||||||
Node.created_at,
|
Node.created_at,
|
||||||
@@ -233,12 +255,15 @@ async def _canvas_correlation(
|
|||||||
).where(Node.design_id.isnot(None))
|
).where(Node.design_id.isnot(None))
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
# Index matching nodes by ip and by ieee so a device can look up both.
|
# Index matching nodes by ip token, mac and ieee so a device can look up any.
|
||||||
by_ip: dict[str, list[Any]] = {}
|
by_ip: dict[str, list[Any]] = {}
|
||||||
|
by_mac: dict[str, list[Any]] = {}
|
||||||
by_ieee: dict[str, list[Any]] = {}
|
by_ieee: dict[str, list[Any]] = {}
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row.ip:
|
for tok in _ip_tokens(row.ip):
|
||||||
by_ip.setdefault(row.ip, []).append(row)
|
by_ip.setdefault(tok, []).append(row)
|
||||||
|
if row.mac:
|
||||||
|
by_mac.setdefault(row.mac, []).append(row)
|
||||||
if row.ieee_address:
|
if row.ieee_address:
|
||||||
by_ieee.setdefault(row.ieee_address, []).append(row)
|
by_ieee.setdefault(row.ieee_address, []).append(row)
|
||||||
|
|
||||||
@@ -247,9 +272,11 @@ async def _canvas_correlation(
|
|||||||
matched = []
|
matched = []
|
||||||
if d.ieee_address:
|
if d.ieee_address:
|
||||||
matched += by_ieee.get(d.ieee_address, [])
|
matched += by_ieee.get(d.ieee_address, [])
|
||||||
if d.ip:
|
if d.mac:
|
||||||
matched += by_ip.get(d.ip, [])
|
matched += by_mac.get(d.mac, [])
|
||||||
# De-duplicate nodes matched by both ip and ieee.
|
for tok in _ip_tokens(d.ip):
|
||||||
|
matched += by_ip.get(tok, [])
|
||||||
|
# De-duplicate nodes matched by more than one identifier.
|
||||||
matched = list({id(m): m for m in matched}.values())
|
matched = list({id(m): m for m in matched}.values())
|
||||||
designs = {m.design_id for m in matched}
|
designs = {m.design_id for m in matched}
|
||||||
info[d.id] = {
|
info[d.id] = {
|
||||||
@@ -308,6 +335,9 @@ async def bulk_approve_devices(
|
|||||||
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]:
|
||||||
|
# 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.
|
# Target the design the user is on; fall back to the first design.
|
||||||
default_design_id = payload.design_id
|
default_design_id = payload.design_id
|
||||||
if default_design_id is None:
|
if default_design_id is None:
|
||||||
@@ -328,27 +358,57 @@ async def bulk_approve_devices(
|
|||||||
devices = result.scalars().all()
|
devices = result.scalars().all()
|
||||||
|
|
||||||
# What already sits on the target canvas, so we skip devices already placed
|
# What already sits on the target canvas, so we skip devices already placed
|
||||||
# here (by ip or ieee_address) instead of creating duplicate nodes.
|
# 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 = (
|
existing = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(Node.ip, Node.ieee_address).where(Node.design_id == default_design_id)
|
select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
|
||||||
|
Node.design_id == default_design_id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
placed_ips = {ip for ip, _ in existing if ip}
|
placed_ips: dict[str, Any] = {
|
||||||
placed_ieee = {ieee for _, ieee in existing if ieee}
|
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] = []
|
created_nodes: list[Node] = []
|
||||||
approved_devices: list[PendingDevice] = []
|
approved_devices: list[PendingDevice] = []
|
||||||
|
skipped_devices: list[dict[str, Any]] = []
|
||||||
for device in devices:
|
for device in devices:
|
||||||
already_here = (
|
# Record which identifier collided so the caller can explain each skip
|
||||||
(device.ip is not None and device.ip in placed_ips)
|
# (and, for existing on-canvas nodes, link to the node already there).
|
||||||
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
|
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
|
||||||
)
|
if ip_hit is not None:
|
||||||
if already_here:
|
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
|
continue
|
||||||
device.status = "approved"
|
device.status = "approved"
|
||||||
node_type = device.suggested_type or "generic"
|
node_type = device.suggested_type or "generic"
|
||||||
is_wireless = _is_wireless(node_type)
|
is_wireless = _is_wireless(node_type)
|
||||||
|
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||||
node = Node(
|
node = Node(
|
||||||
label=device.hostname or device.friendly_name or device.ip or "device",
|
label=device.hostname or device.friendly_name or device.ip or "device",
|
||||||
type=node_type,
|
type=node_type,
|
||||||
@@ -360,29 +420,43 @@ async def bulk_approve_devices(
|
|||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=_wireless_properties(
|
properties=_wireless_properties(
|
||||||
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
node_type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if is_wireless else build_mac_property(device.mac),
|
) 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.
|
# Default to ping so the status checker actually polls the new node.
|
||||||
# Without this the scheduler skips it (check_method NULL → no check).
|
# Without this the scheduler skips it (check_method NULL → no check).
|
||||||
check_method="none" if is_wireless else ("ping" if device.ip else None),
|
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,
|
design_id=default_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
created_nodes.append(node)
|
created_nodes.append(node)
|
||||||
approved_devices.append(device)
|
approved_devices.append(device)
|
||||||
# Track within this batch so a duplicate selection (same ip/ieee) is not
|
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
|
||||||
# placed twice on the same canvas.
|
# not placed twice on the same canvas. Store the Node so a later in-batch
|
||||||
if device.ip:
|
# skip can resolve to its id after flush.
|
||||||
placed_ips.add(device.ip)
|
for tok in _ip_tokens(device.ip):
|
||||||
|
placed_ips[tok] = node
|
||||||
|
if device.mac:
|
||||||
|
placed_mac[device.mac] = node
|
||||||
if device.ieee_address:
|
if device.ieee_address:
|
||||||
placed_ieee.add(device.ieee_address)
|
placed_ieee[device.ieee_address] = node
|
||||||
await db.flush() # populates node.id from Python-side default before reading
|
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 and approved_device_ids stay index-aligned for the client's mapping.
|
||||||
node_ids = [n.id for n in created_nodes]
|
node_ids = [n.id for n in created_nodes]
|
||||||
approved_device_ids = [d.id for d in approved_devices]
|
approved_device_ids = [d.id for d in approved_devices]
|
||||||
|
|
||||||
|
# Resolve each skip's existing-node reference to a concrete id now that any
|
||||||
|
# in-batch nodes have been flushed, and expose it under a clean key.
|
||||||
|
for entry in skipped_devices:
|
||||||
|
ref = entry.pop("_ref")
|
||||||
|
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
|
||||||
|
|
||||||
all_edges: list[dict[str, str]] = []
|
all_edges: list[dict[str, str]] = []
|
||||||
for device in approved_devices:
|
for device in approved_devices:
|
||||||
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
|
all_edges.extend(
|
||||||
|
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
@@ -392,6 +466,7 @@ async def bulk_approve_devices(
|
|||||||
"edges_created": len(all_edges),
|
"edges_created": len(all_edges),
|
||||||
"edges": all_edges,
|
"edges": all_edges,
|
||||||
"skipped": len(payload.device_ids) - len(node_ids),
|
"skipped": len(payload.device_ids) - len(node_ids),
|
||||||
|
"skipped_devices": skipped_devices,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -465,13 +540,39 @@ async def approve_device(
|
|||||||
device = await db.get(PendingDevice, device_id)
|
device = await db.get(PendingDevice, device_id)
|
||||||
if not device:
|
if not device:
|
||||||
raise HTTPException(status_code=404, detail="Device not found")
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
if device.status != "pending":
|
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
|
||||||
raise HTTPException(status_code=409, detail="Device already processed")
|
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
|
||||||
device.status = "approved"
|
# 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)
|
wireless = _is_wireless(node_data.type)
|
||||||
|
|
||||||
|
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
|
||||||
|
# again automatically: the user might genuinely want a second card, or might
|
||||||
|
# be re-approving by mistake. Reject with 409 + the existing node so the UI
|
||||||
|
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
|
||||||
|
# hosts. force=True (set after the user confirms) skips this and creates it.
|
||||||
|
# The same device on a *different* design is valid (one Node per canvas), so
|
||||||
|
# this is scoped to node_design_id.
|
||||||
|
if not node_data.force:
|
||||||
|
conflict = await find_duplicate_node(
|
||||||
|
db, node_design_id,
|
||||||
|
node_data.ip or device.ip,
|
||||||
|
node_data.mac or device.mac,
|
||||||
|
ieee=device.ieee_address,
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
raise HTTPException(status_code=409, detail=conflict)
|
||||||
|
|
||||||
|
device.status = "approved"
|
||||||
# Prefer the MAC discovered during the scan (stored on the pending device);
|
# Prefer the MAC discovered during the scan (stored on the pending device);
|
||||||
# fall back to whatever the approve payload carried.
|
# fall back to whatever the approve payload carried.
|
||||||
_mac = device.mac or node_data.mac
|
_mac = device.mac or node_data.mac
|
||||||
|
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
|
||||||
node = Node(
|
node = Node(
|
||||||
label=node_data.label,
|
label=node_data.label,
|
||||||
type=node_data.type,
|
type=node_data.type,
|
||||||
@@ -483,16 +584,22 @@ async def approve_device(
|
|||||||
ieee_address=device.ieee_address,
|
ieee_address=device.ieee_address,
|
||||||
properties=_wireless_properties(
|
properties=_wireless_properties(
|
||||||
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
|
||||||
) if wireless else merge_mac_property(node_data.properties, _mac),
|
) 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_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,
|
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,
|
design_id=node_design_id,
|
||||||
)
|
)
|
||||||
db.add(node)
|
db.add(node)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
node_id = node.id
|
node_id = node.id
|
||||||
|
|
||||||
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address)
|
edges = await _resolve_pending_links_for_ieee(db, device.ieee_address, node_design_id)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return {
|
return {
|
||||||
@@ -503,15 +610,40 @@ async def approve_device(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_pending_links_for_ieee(
|
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
|
||||||
db: AsyncSession, ieee: str | None
|
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
|
||||||
) -> list[dict[str, str]]:
|
|
||||||
"""Materialize edges for any pending_device_links involving ``ieee``.
|
|
||||||
|
|
||||||
For each link where the other endpoint already exists as a canvas Node
|
Such a host needs one left + one right handle for the cluster edge endpoints
|
||||||
(matched by ``Node.ieee_address``), create the Edge and drop the link
|
(both default to 0). Checked at approve time, before the link is consumed by
|
||||||
row. Links where the other endpoint is still pending are kept so they
|
``_resolve_pending_links_for_ieee``.
|
||||||
can resolve when that endpoint is approved later.
|
"""
|
||||||
|
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:
|
if not ieee:
|
||||||
return []
|
return []
|
||||||
@@ -526,14 +658,19 @@ async def _resolve_pending_links_for_ieee(
|
|||||||
if not links:
|
if not links:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Map every relevant ieee → Node (single query).
|
# Map every relevant ieee → Node *on the target design* (single query).
|
||||||
|
# Scoping by design is what makes a re-approve onto a second canvas link the
|
||||||
|
# nodes of THAT canvas, not stale nodes left on another one.
|
||||||
other_ieees = {
|
other_ieees = {
|
||||||
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
link.target_ieee if link.source_ieee == ieee else link.source_ieee
|
||||||
for link in links
|
for link in links
|
||||||
}
|
}
|
||||||
other_ieees.add(ieee)
|
other_ieees.add(ieee)
|
||||||
nodes_q = await db.execute(
|
nodes_q = await db.execute(
|
||||||
select(Node).where(Node.ieee_address.in_(other_ieees))
|
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}
|
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
|
||||||
|
|
||||||
@@ -564,28 +701,48 @@ async def _resolve_pending_links_for_ieee(
|
|||||||
src_id, tgt_id = self_node.id, other_node.id
|
src_id, tgt_id = self_node.id, other_node.id
|
||||||
else:
|
else:
|
||||||
src_id, tgt_id = other_node.id, self_node.id
|
src_id, tgt_id = other_node.id, self_node.id
|
||||||
# Skip if either direction already exists.
|
# Skip if either direction already exists on this design (re-approve or
|
||||||
|
# a manually drawn link). The link row is kept for other designs.
|
||||||
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
if (src_id, tgt_id) in existing_pairs or (tgt_id, src_id) in existing_pairs:
|
||||||
await db.delete(link)
|
|
||||||
continue
|
continue
|
||||||
# Use the source node's design_id for the edge
|
# Edge lands on the design we're approving into (the nodes above are
|
||||||
edge_design_id = self_node.design_id if self_node else None
|
# already scoped to it).
|
||||||
if edge_design_id is None:
|
edge_design_id = design_id or (self_node.design_id if self_node else None)
|
||||||
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
|
||||||
edge_design_id = first.id if first else None
|
# (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(
|
edge = Edge(
|
||||||
source=src_id,
|
source=src_id,
|
||||||
target=tgt_id,
|
target=tgt_id,
|
||||||
type="iot",
|
type=edge_type,
|
||||||
source_handle="bottom",
|
source_handle=src_handle,
|
||||||
target_handle="top-t",
|
target_handle=tgt_handle,
|
||||||
design_id=edge_design_id,
|
design_id=edge_design_id,
|
||||||
)
|
)
|
||||||
db.add(edge)
|
db.add(edge)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
existing_pairs.add((src_id, tgt_id))
|
existing_pairs.add((src_id, tgt_id))
|
||||||
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
|
# Return the edge's type + handles so the client injects it faithfully
|
||||||
await db.delete(link)
|
# (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
|
return created
|
||||||
|
|
||||||
|
|||||||
@@ -10,19 +10,24 @@ 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.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.scan import ScanRunResponse
|
from app.schemas.scan import ScanRunResponse
|
||||||
from app.schemas.zigbee import (
|
from app.schemas.zigbee import (
|
||||||
|
ZigbeeConfig,
|
||||||
ZigbeeCoordinatorOut,
|
ZigbeeCoordinatorOut,
|
||||||
ZigbeeEdgeOut,
|
ZigbeeEdgeOut,
|
||||||
ZigbeeImportPendingResponse,
|
ZigbeeImportPendingResponse,
|
||||||
ZigbeeImportRequest,
|
ZigbeeImportRequest,
|
||||||
ZigbeeImportResponse,
|
ZigbeeImportResponse,
|
||||||
ZigbeeNodeOut,
|
ZigbeeNodeOut,
|
||||||
|
ZigbeeSyncConfig,
|
||||||
ZigbeeTestConnectionRequest,
|
ZigbeeTestConnectionRequest,
|
||||||
ZigbeeTestConnectionResponse,
|
ZigbeeTestConnectionResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||||
from app.services.zigbee_service import (
|
from app.services.zigbee_service import (
|
||||||
build_zigbee_properties,
|
build_zigbee_properties,
|
||||||
fetch_networkmap,
|
fetch_networkmap,
|
||||||
@@ -98,6 +103,53 @@ async def import_zigbee_to_pending(
|
|||||||
return run
|
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 def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
@@ -138,10 +190,12 @@ async def _persist_pending_import(
|
|||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by IEEE.
|
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.
|
All zigbee-source links are wiped and re-inserted from the new map.
|
||||||
"""
|
"""
|
||||||
# Determine target design (use first design as fallback)
|
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
# the by-IEEE lookups below resolve to a single row.
|
||||||
default_design_id = first_design.id if first_design else None
|
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_out: ZigbeeCoordinatorOut | None = None
|
||||||
coordinator_existed = False
|
coordinator_existed = False
|
||||||
pending_created = 0
|
pending_created = 0
|
||||||
@@ -155,49 +209,57 @@ async def _persist_pending_import(
|
|||||||
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
|
||||||
)
|
)
|
||||||
|
|
||||||
if n.get("device_type") == "Coordinator":
|
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
# the pending inventory like every other device, so the user approves it
|
||||||
existing_node = existing.scalar_one_or_none()
|
# explicitly. Only the shared paths below run for it.
|
||||||
if existing_node:
|
|
||||||
|
# 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 = merge_zigbee_properties(
|
||||||
existing_node.properties, props
|
existing_node.properties, props
|
||||||
)
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
inv = (
|
||||||
id=existing_node.id,
|
await db.execute(
|
||||||
label=existing_node.label,
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
ieee_address=ieee,
|
|
||||||
)
|
)
|
||||||
coordinator_existed = True
|
).scalar_one_or_none()
|
||||||
continue
|
if inv is None:
|
||||||
label = n.get("friendly_name") or ieee
|
db.add(
|
||||||
node = Node(
|
PendingDevice(
|
||||||
label=label,
|
ieee_address=ieee,
|
||||||
type=n.get("type") or "zigbee_coordinator",
|
friendly_name=n.get("friendly_name"),
|
||||||
status="online",
|
hostname=n.get("friendly_name"),
|
||||||
check_method="none",
|
suggested_type=n.get("type"),
|
||||||
ieee_address=ieee,
|
device_subtype=n.get("device_type"),
|
||||||
services=[],
|
model=n.get("model"),
|
||||||
properties=props,
|
vendor=n.get("vendor"),
|
||||||
design_id=default_design_id,
|
lqi=n.get("lqi"),
|
||||||
)
|
status="approved",
|
||||||
db.add(node)
|
discovery_source="zigbee",
|
||||||
await db.flush()
|
)
|
||||||
coordinator_out = ZigbeeCoordinatorOut(
|
)
|
||||||
id=node.id, label=label, ieee_address=ieee
|
pending_created += 1
|
||||||
)
|
else:
|
||||||
continue
|
# Refresh metadata but never change the row's status (an approved
|
||||||
|
# device stays approved; a hidden one stays hidden).
|
||||||
# If the device has already been approved as a canvas Node, refresh
|
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||||
# its properties and skip creating a pending row (keeps approved
|
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||||
# devices out of pending/hidden modals on re-import).
|
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||||
existing_node_q = await db.execute(
|
inv.model = n.get("model") or inv.model
|
||||||
select(Node).where(Node.ieee_address == ieee)
|
inv.vendor = n.get("vendor") or inv.vendor
|
||||||
)
|
if n.get("lqi") is not None:
|
||||||
existing_node = existing_node_q.scalar_one_or_none()
|
inv.lqi = n.get("lqi")
|
||||||
if existing_node:
|
pending_updated += 1
|
||||||
existing_node.properties = merge_zigbee_properties(
|
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -296,3 +358,50 @@ async def test_zigbee_connection(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Unexpected error during connection test")
|
logger.exception("Unexpected error during connection test")
|
||||||
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
|
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()
|
||||||
|
|||||||
+153
-40
@@ -10,19 +10,24 @@ 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.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
|
||||||
from app.db.database import AsyncSessionLocal, get_db
|
from app.db.database import AsyncSessionLocal, get_db
|
||||||
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
|
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
|
||||||
from app.schemas.scan import ScanRunResponse
|
from app.schemas.scan import ScanRunResponse
|
||||||
from app.schemas.zwave import (
|
from app.schemas.zwave import (
|
||||||
|
ZwaveConfig,
|
||||||
ZwaveCoordinatorOut,
|
ZwaveCoordinatorOut,
|
||||||
ZwaveEdgeOut,
|
ZwaveEdgeOut,
|
||||||
ZwaveImportPendingResponse,
|
ZwaveImportPendingResponse,
|
||||||
ZwaveImportRequest,
|
ZwaveImportRequest,
|
||||||
ZwaveImportResponse,
|
ZwaveImportResponse,
|
||||||
ZwaveNodeOut,
|
ZwaveNodeOut,
|
||||||
|
ZwaveSyncConfig,
|
||||||
ZwaveTestConnectionRequest,
|
ZwaveTestConnectionRequest,
|
||||||
ZwaveTestConnectionResponse,
|
ZwaveTestConnectionResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.node_dedupe import dedupe_nodes_by_ieee
|
||||||
from app.services.zwave_service import (
|
from app.services.zwave_service import (
|
||||||
build_zwave_properties,
|
build_zwave_properties,
|
||||||
fetch_zwave_network,
|
fetch_zwave_network,
|
||||||
@@ -93,6 +98,54 @@ async def import_zwave_to_pending(
|
|||||||
return run
|
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 def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
try:
|
try:
|
||||||
@@ -134,9 +187,12 @@ async def _persist_pending_import(
|
|||||||
Coordinator auto-approves to a canvas Node. Other devices upsert by Z-Wave
|
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.
|
identity. All zwave-source links are wiped and re-inserted from the new map.
|
||||||
"""
|
"""
|
||||||
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
|
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
|
||||||
default_design_id = first_design.id if first_design else None
|
# 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_out: ZwaveCoordinatorOut | None = None
|
||||||
coordinator_existed = False
|
coordinator_existed = False
|
||||||
pending_created = 0
|
pending_created = 0
|
||||||
@@ -148,47 +204,56 @@ async def _persist_pending_import(
|
|||||||
continue
|
continue
|
||||||
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
|
||||||
|
|
||||||
if n.get("type") == "zwave_coordinator":
|
# The coordinator is no longer auto-placed on the canvas — it flows to
|
||||||
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
|
# the pending inventory like every other device, so the user approves it
|
||||||
existing_node = existing.scalar_one_or_none()
|
# explicitly. Only the shared paths below run for it.
|
||||||
if existing_node:
|
|
||||||
|
# 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 = merge_zwave_properties(
|
||||||
existing_node.properties, props
|
existing_node.properties, props
|
||||||
)
|
)
|
||||||
coordinator_out = ZwaveCoordinatorOut(
|
inv = (
|
||||||
id=existing_node.id,
|
await db.execute(
|
||||||
label=existing_node.label,
|
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
|
||||||
ieee_address=ieee,
|
|
||||||
)
|
)
|
||||||
coordinator_existed = True
|
).scalar_one_or_none()
|
||||||
continue
|
if inv is None:
|
||||||
label = n.get("friendly_name") or ieee
|
db.add(
|
||||||
node = Node(
|
PendingDevice(
|
||||||
label=label,
|
ieee_address=ieee,
|
||||||
type=n.get("type") or "zwave_coordinator",
|
friendly_name=n.get("friendly_name"),
|
||||||
status="online",
|
hostname=n.get("friendly_name"),
|
||||||
check_method="none",
|
suggested_type=n.get("type"),
|
||||||
ieee_address=ieee,
|
device_subtype=n.get("device_type"),
|
||||||
services=[],
|
model=n.get("model"),
|
||||||
properties=props,
|
vendor=n.get("vendor"),
|
||||||
design_id=default_design_id,
|
lqi=n.get("lqi"),
|
||||||
)
|
status="approved",
|
||||||
db.add(node)
|
discovery_source="zwave",
|
||||||
await db.flush()
|
)
|
||||||
coordinator_out = ZwaveCoordinatorOut(
|
)
|
||||||
id=node.id, label=label, ieee_address=ieee
|
pending_created += 1
|
||||||
)
|
else:
|
||||||
continue
|
# Refresh metadata but never change the row's status.
|
||||||
|
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
|
||||||
# Already approved as a canvas Node → refresh props, skip pending row.
|
inv.suggested_type = n.get("type") or inv.suggested_type
|
||||||
existing_node_q = await db.execute(
|
inv.device_subtype = n.get("device_type") or inv.device_subtype
|
||||||
select(Node).where(Node.ieee_address == ieee)
|
inv.model = n.get("model") or inv.model
|
||||||
)
|
inv.vendor = n.get("vendor") or inv.vendor
|
||||||
existing_node = existing_node_q.scalar_one_or_none()
|
if n.get("lqi") is not None:
|
||||||
if existing_node:
|
inv.lqi = n.get("lqi")
|
||||||
existing_node.properties = merge_zwave_properties(
|
pending_updated += 1
|
||||||
existing_node.properties, props
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
@@ -282,3 +347,51 @@ async def test_connection_endpoint(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Unexpected error during connection test")
|
logger.exception("Unexpected error during connection test")
|
||||||
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
|
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()
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
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
|
||||||
@@ -48,6 +52,11 @@ class Settings(BaseSettings):
|
|||||||
# 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).
|
# 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.
|
# http_ranges: extra nmap port ranges, opt-in, no default. Probe + TLS off by default.
|
||||||
scanner_http_ranges: list[str] = []
|
scanner_http_ranges: list[str] = []
|
||||||
@@ -76,9 +85,55 @@ class Settings(BaseSettings):
|
|||||||
# Leave empty to keep the feature disabled (default).
|
# Leave empty to keep the feature disabled (default).
|
||||||
homepage_api_key: str = ""
|
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:
|
||||||
@@ -97,6 +152,25 @@ class Settings(BaseSettings):
|
|||||||
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
|
||||||
if "scanner_http_verify_tls" in data:
|
if "scanner_http_verify_tls" in data:
|
||||||
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
|
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
|
||||||
|
|
||||||
@@ -111,6 +185,17 @@ class Settings(BaseSettings):
|
|||||||
"scanner_http_ranges": self.scanner_http_ranges,
|
"scanner_http_ranges": self.scanner_http_ranges,
|
||||||
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
|
||||||
"scanner_http_verify_tls": self.scanner_http_verify_tls,
|
"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,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""APScheduler setup for background scan and status check jobs."""
|
"""APScheduler setup for background scan and status check jobs."""
|
||||||
import asyncio
|
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,6 +13,10 @@ 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, check_services
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -106,6 +112,97 @@ async def _run_service_checks() -> None:
|
|||||||
logger.error("Service checks failed for node %s: %s", node_id, 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:
|
def _add_service_check_job() -> None:
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
_run_service_checks,
|
_run_service_checks,
|
||||||
@@ -117,6 +214,39 @@ def _add_service_check_job() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
if scheduler.running:
|
||||||
@@ -135,6 +265,12 @@ def start_scheduler() -> None:
|
|||||||
)
|
)
|
||||||
if settings.service_check_enabled:
|
if settings.service_check_enabled:
|
||||||
_add_service_check_job()
|
_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)
|
||||||
|
|
||||||
@@ -175,6 +311,81 @@ def set_service_checks_enabled(enabled: bool) -> None:
|
|||||||
logger.info("Service checks disabled")
|
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:
|
if scheduler.running:
|
||||||
scheduler.shutdown(wait=False)
|
scheduler.shutdown(wait=False)
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ async def init_db() -> None:
|
|||||||
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(OperationalError):
|
||||||
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):
|
||||||
|
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):
|
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(OperationalError):
|
||||||
@@ -99,8 +107,18 @@ async def init_db() -> None:
|
|||||||
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
|
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
|
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):
|
with suppress(OperationalError):
|
||||||
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
|
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 schema migrations (logged variant per CLAUDE.md feedback) ---
|
||||||
@@ -148,6 +166,7 @@ async def init_db() -> None:
|
|||||||
"model VARCHAR,"
|
"model VARCHAR,"
|
||||||
"vendor VARCHAR,"
|
"vendor VARCHAR,"
|
||||||
"lqi INTEGER,"
|
"lqi INTEGER,"
|
||||||
|
"properties JSON,"
|
||||||
"discovered_at DATETIME"
|
"discovered_at DATETIME"
|
||||||
")"
|
")"
|
||||||
)
|
)
|
||||||
@@ -302,6 +321,37 @@ async def init_db() -> None:
|
|||||||
with suppress(OperationalError):
|
with suppress(OperationalError):
|
||||||
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
|
||||||
await conn.exec_driver_sql(sql)
|
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]:
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ class Node(Base):
|
|||||||
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)
|
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)
|
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))
|
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -82,7 +85,11 @@ class Edge(Base):
|
|||||||
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)
|
||||||
|
width_mult: Mapped[float | None] = mapped_column(Float)
|
||||||
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
|
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)
|
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
|
||||||
@@ -112,13 +119,23 @@ class PendingDevice(Base):
|
|||||||
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)
|
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)
|
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
|
||||||
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
device_subtype: 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)
|
model: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||||
vendor: 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)
|
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)
|
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
# Transient (not persisted): populated per-request by the scan routes to report
|
# Transient (not persisted): populated per-request by the scan routes to report
|
||||||
|
|||||||
+17
-1
@@ -7,7 +7,21 @@ 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 auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave
|
from app.api.routes import (
|
||||||
|
auth,
|
||||||
|
canvas,
|
||||||
|
designs,
|
||||||
|
edges,
|
||||||
|
liveview,
|
||||||
|
media,
|
||||||
|
nodes,
|
||||||
|
proxmox,
|
||||||
|
scan,
|
||||||
|
stats,
|
||||||
|
status,
|
||||||
|
zigbee,
|
||||||
|
zwave,
|
||||||
|
)
|
||||||
from app.api.routes import settings as settings_routes
|
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
|
||||||
@@ -58,7 +72,9 @@ app.include_router(settings_routes.router, prefix="/api/v1/settings", tags=["set
|
|||||||
app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"])
|
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(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
|
||||||
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
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(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")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pydantic import BaseModel, field_validator
|
|||||||
|
|
||||||
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
|
from app.schemas.utils import normalize_animated, normalize_marker
|
||||||
|
|
||||||
|
|
||||||
class NodeSave(BaseModel):
|
class NodeSave(BaseModel):
|
||||||
@@ -34,6 +34,9 @@ class NodeSave(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
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
|
||||||
|
|
||||||
@@ -48,7 +51,11 @@ 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
|
||||||
|
width_mult: float | None = None
|
||||||
animated: str = '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
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -58,6 +65,11 @@ class EdgeSave(BaseModel):
|
|||||||
def validate_animated(cls, v: object) -> str:
|
def validate_animated(cls, v: object) -> str:
|
||||||
return normalize_animated(v)
|
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] = []
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ class DesignUpdate(BaseModel):
|
|||||||
icon: 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):
|
class DesignResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@@ -23,5 +30,10 @@ class DesignResponse(BaseModel):
|
|||||||
icon: str | None = None
|
icon: str | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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}
|
model_config = {"from_attributes": True}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from app.schemas.utils import normalize_animated
|
from app.schemas.utils import normalize_animated, normalize_marker
|
||||||
|
|
||||||
|
|
||||||
class EdgeBase(BaseModel):
|
class EdgeBase(BaseModel):
|
||||||
@@ -14,7 +14,11 @@ 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
|
||||||
|
width_mult: float | None = None
|
||||||
animated: str = '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
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -24,6 +28,11 @@ class EdgeBase(BaseModel):
|
|||||||
def validate_animated(cls, v: object) -> str:
|
def validate_animated(cls, v: object) -> str:
|
||||||
return normalize_animated(v)
|
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
|
design_id: str | None = None
|
||||||
@@ -36,7 +45,11 @@ 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
|
||||||
|
width_mult: float | None = None
|
||||||
animated: str | 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
|
waypoints: list[dict[str, float]] | None = None
|
||||||
@@ -48,6 +61,13 @@ class EdgeUpdate(BaseModel):
|
|||||||
return None
|
return None
|
||||||
return normalize_animated(v)
|
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
|
||||||
|
|||||||
@@ -32,10 +32,21 @@ class NodeBase(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int = 1
|
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".
|
||||||
|
# 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
|
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):
|
||||||
@@ -66,6 +77,9 @@ class NodeUpdate(BaseModel):
|
|||||||
width: float | None = None
|
width: float | None = None
|
||||||
height: float | None = None
|
height: float | None = None
|
||||||
bottom_handles: int | 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):
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Pydantic v2 schemas for Proxmox VE import.
|
||||||
|
|
||||||
|
Token fields are accepted on requests only and are optional — when omitted the
|
||||||
|
backend falls back to the server-configured token (env). No response schema ever
|
||||||
|
carries a token; secrets are kept out of responses by structural omission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxConnectionRequest(BaseModel):
|
||||||
|
host: str = Field(..., description="Proxmox VE host or IP")
|
||||||
|
port: int = Field(8006, ge=1, le=65535, description="Proxmox API port")
|
||||||
|
token_id: str | None = Field(
|
||||||
|
None, description="API token id 'user@realm!tokenname' (falls back to server env)"
|
||||||
|
)
|
||||||
|
token_secret: str | None = Field(
|
||||||
|
None, description="API token secret (falls back to server env)"
|
||||||
|
)
|
||||||
|
verify_tls: bool = Field(True, description="Verify the Proxmox TLS certificate")
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxTestConnectionResponse(BaseModel):
|
||||||
|
connected: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxNodeOut(BaseModel):
|
||||||
|
"""A homelable-ready node representation of a Proxmox host / VM / LXC."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
type: str # proxmox | vm | lxc
|
||||||
|
ieee_address: str
|
||||||
|
hostname: str | None = None
|
||||||
|
ip: str | None = None
|
||||||
|
status: str
|
||||||
|
cpu_count: int | None = None
|
||||||
|
ram_gb: float | None = None
|
||||||
|
disk_gb: float | None = None
|
||||||
|
vendor: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
parent_ieee: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxEdgeOut(BaseModel):
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxImportResponse(BaseModel):
|
||||||
|
nodes: list[ProxmoxNodeOut]
|
||||||
|
edges: list[ProxmoxEdgeOut]
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxImportPendingResponse(BaseModel):
|
||||||
|
"""Result of importing a Proxmox inventory into the pending section."""
|
||||||
|
|
||||||
|
pending_created: int
|
||||||
|
pending_updated: int
|
||||||
|
links_recorded: int
|
||||||
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxConfig(BaseModel):
|
||||||
|
"""Non-secret Proxmox connection + auto-sync config (GET response).
|
||||||
|
|
||||||
|
Connection fields (host/port/verify_tls) are env-only and read-only here —
|
||||||
|
surfaced for display. ``token_configured`` reflects whether a server-side
|
||||||
|
token is present. Never carries the token itself."""
|
||||||
|
|
||||||
|
host: str = ""
|
||||||
|
port: int = Field(8006, ge=1, le=65535)
|
||||||
|
verify_tls: bool = True
|
||||||
|
sync_enabled: bool = False
|
||||||
|
sync_interval: int = Field(3600, ge=300)
|
||||||
|
token_configured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ProxmoxSyncConfig(BaseModel):
|
||||||
|
"""User-editable auto-sync config (POST body). The ONLY persisted Proxmox
|
||||||
|
settings. Connection fields (host/port/token/verify_tls) are env-only and
|
||||||
|
are deliberately not accepted here."""
|
||||||
|
|
||||||
|
sync_enabled: bool = False
|
||||||
|
sync_interval: int = Field(3600, ge=300)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
class PendingDeviceResponse(BaseModel):
|
class PendingDeviceResponse(BaseModel):
|
||||||
@@ -14,12 +14,18 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
suggested_type: str | None
|
suggested_type: str | None
|
||||||
status: str
|
status: str
|
||||||
discovery_source: str | None
|
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
|
ieee_address: str | None = None
|
||||||
friendly_name: str | None = None
|
friendly_name: str | None = None
|
||||||
device_subtype: str | None = None
|
device_subtype: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
vendor: str | None = None
|
vendor: str | None = None
|
||||||
lqi: int | 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,
|
# Number of distinct canvases (designs) this device already appears on,
|
||||||
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
# correlated by ip / ieee_address against existing nodes. Computed per-request.
|
||||||
@@ -32,6 +38,12 @@ class PendingDeviceResponse(BaseModel):
|
|||||||
node_last_modified: datetime | None = None
|
node_last_modified: datetime | None = None
|
||||||
node_last_seen: 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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str:
|
|||||||
if v in ('snake', 'flow', 'basic'):
|
if v in ('snake', 'flow', 'basic'):
|
||||||
return str(v)
|
return str(v)
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|
||||||
|
|
||||||
|
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_marker(v: object) -> str:
|
||||||
|
"""Normalize an edge endpoint marker to a shape string.
|
||||||
|
|
||||||
|
Legacy saves stored a boolean (True = filled arrow); coerce those and any
|
||||||
|
unknown value to a valid MarkerShape ('none' when off/unknown).
|
||||||
|
"""
|
||||||
|
if v is True or v == 1 or v == '1':
|
||||||
|
return 'arrow'
|
||||||
|
if v is False or v == 0 or v == '0' or v is None:
|
||||||
|
return 'none'
|
||||||
|
if isinstance(v, str) and v in MARKER_SHAPES:
|
||||||
|
return v
|
||||||
|
return 'none'
|
||||||
|
|||||||
@@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel):
|
|||||||
coordinator_already_existed: bool = False
|
coordinator_already_existed: bool = False
|
||||||
links_recorded: int
|
links_recorded: int
|
||||||
device_count: 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)
|
||||||
|
|||||||
@@ -83,3 +83,30 @@ class ZwaveImportPendingResponse(BaseModel):
|
|||||||
coordinator_already_existed: bool = False
|
coordinator_already_existed: bool = False
|
||||||
links_recorded: int
|
links_recorded: int
|
||||||
device_count: int
|
device_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveConfig(BaseModel):
|
||||||
|
"""Non-secret Z-Wave connection + auto-sync config (GET response).
|
||||||
|
|
||||||
|
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
|
||||||
|
read-only here — surfaced for display. ``host_configured`` reflects whether
|
||||||
|
a server-side MQTT host is set (required for auto-sync). MQTT credentials
|
||||||
|
(username/password) are never carried."""
|
||||||
|
|
||||||
|
mqtt_host: str = ""
|
||||||
|
mqtt_port: int = Field(1883, ge=1, le=65535)
|
||||||
|
prefix: str = "zwave"
|
||||||
|
gateway_name: str = "zwavejs2mqtt"
|
||||||
|
mqtt_tls: bool = False
|
||||||
|
sync_enabled: bool = False
|
||||||
|
sync_interval: int = Field(3600, ge=300)
|
||||||
|
host_configured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ZwaveSyncConfig(BaseModel):
|
||||||
|
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
|
||||||
|
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
|
||||||
|
env-only and are deliberately not accepted here."""
|
||||||
|
|
||||||
|
sync_enabled: bool = False
|
||||||
|
sync_interval: int = Field(3600, ge=300)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Helpers for the multi-valued ``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
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""MAC-address normalization, shared by the scan + Proxmox persist paths.
|
||||||
|
|
||||||
|
Different discovery sources emit MACs in different casing/separators (ARP is
|
||||||
|
lowercase ``bc:24:11:..``, Proxmox config is often uppercase ``BC:24:11:..``).
|
||||||
|
Canonicalizing on write *and* on compare lets cross-source dedup match a device
|
||||||
|
by MAC with a plain ``==`` — the join key for merging an IP-scanned row with a
|
||||||
|
Proxmox-imported one.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_mac(mac: str | None) -> str | None:
|
||||||
|
"""Canonical MAC: lowercase, ``-`` → ``:``, stripped. Blank/None → None."""
|
||||||
|
if not mac:
|
||||||
|
return None
|
||||||
|
normalized = mac.strip().lower().replace("-", ":")
|
||||||
|
return normalized or None
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
"""Proxmox VE inventory service: fetch hosts + VMs + LXC via the PVE REST API.
|
||||||
|
|
||||||
|
Mirrors the Zigbee/Z-Wave import pipeline, but talks to the Proxmox VE REST API
|
||||||
|
(``/api2/json``) over HTTPS with an API token instead of MQTT. It returns plain
|
||||||
|
homelable node dicts + parent→child edge hints; DB persistence lives in the
|
||||||
|
route layer (``app.api.routes.proxmox``).
|
||||||
|
|
||||||
|
Auth uses a Proxmox **API token** (never a password):
|
||||||
|
``Authorization: PVEAPIToken=<token_id>=<secret>`` where ``token_id`` looks like
|
||||||
|
``user@realm!tokenname``. A read-only ``PVEAuditor`` role is all that is needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.services.mac_utils import normalize_mac
|
||||||
|
from app.services.zigbee_service import merge_zigbee_properties
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Reuse the zigbee property-merge contract verbatim (same NodeProperty shape +
|
||||||
|
# visibility-preservation rules) for re-sync updates.
|
||||||
|
merge_proxmox_properties = merge_zigbee_properties
|
||||||
|
|
||||||
|
_CONNECT_TIMEOUT = 8.0
|
||||||
|
_READ_TIMEOUT = 20.0
|
||||||
|
_BYTES_PER_GB = 1024 ** 3
|
||||||
|
|
||||||
|
# net0 config line: "name=eth0,bridge=vmbr0,ip=192.168.1.5/24,gw=..."
|
||||||
|
_LXC_IP_RE = re.compile(r"(?:^|,)ip=([0-9]{1,3}(?:\.[0-9]{1,3}){3})(?:/\d+)?")
|
||||||
|
# NIC MAC inside a net0 string — qemu "virtio=BC:24:11:..,bridge=.." or lxc
|
||||||
|
# "..,hwaddr=BC:24:11:..,..". A bare 6-octet MAC match works for both forms.
|
||||||
|
_NET_MAC_RE = re.compile(r"([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_proxmox_error(exc: BaseException) -> str:
|
||||||
|
"""Return a generic, credential-free message for a Proxmox/HTTP error.
|
||||||
|
|
||||||
|
Raw httpx errors can echo the request URL and, worse, an
|
||||||
|
``Authorization: PVEAPIToken=...=<secret>`` header in some stacks. Map known
|
||||||
|
patterns to coarse categories so the token never reaches an API client. The
|
||||||
|
original exception is logged at WARNING for operators.
|
||||||
|
"""
|
||||||
|
logger.warning("Proxmox error (sanitized for client): %r", exc)
|
||||||
|
if isinstance(exc, httpx.HTTPStatusError):
|
||||||
|
code = exc.response.status_code
|
||||||
|
if code in (401, 403):
|
||||||
|
return "Authentication failed — check the API token and its permissions"
|
||||||
|
if code == 404:
|
||||||
|
return "Proxmox API path not found — is this a Proxmox VE host?"
|
||||||
|
return f"Proxmox API returned HTTP {code}"
|
||||||
|
raw = str(exc).lower()
|
||||||
|
if "name or service not known" in raw or "getaddrinfo" in raw or "nodename nor servname" in raw:
|
||||||
|
return "Proxmox host could not be resolved"
|
||||||
|
if "refused" in raw:
|
||||||
|
return "Connection refused by Proxmox host"
|
||||||
|
if "certificate" in raw or "ssl" in raw or "tls" in raw:
|
||||||
|
return "TLS verification failed — enable 'skip TLS verify' for self-signed certs"
|
||||||
|
if "timed out" in raw or "timeout" in raw:
|
||||||
|
return "Connection to Proxmox host timed out"
|
||||||
|
return "Proxmox connection failed"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(token_id: str, token_secret: str) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"PVEAPIToken={token_id}={token_secret}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _gb(value: Any) -> float | None:
|
||||||
|
"""Convert a byte count to GB (1 decimal). None/0 → None."""
|
||||||
|
try:
|
||||||
|
num = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if num <= 0:
|
||||||
|
return None
|
||||||
|
return round(num / _BYTES_PER_GB, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _int_or_none(value: Any) -> int | None:
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_type_to_homelable(kind: str) -> str:
|
||||||
|
"""qemu → vm, lxc → lxc (both existing homelable node types)."""
|
||||||
|
return "vm" if kind == "qemu" else "lxc"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_qemu_ip(agent_payload: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Pull the first non-loopback IPv4 from a qemu guest-agent interfaces reply."""
|
||||||
|
if not agent_payload:
|
||||||
|
return None
|
||||||
|
result = agent_payload.get("result")
|
||||||
|
if not isinstance(result, list):
|
||||||
|
return None
|
||||||
|
for iface in result:
|
||||||
|
if not isinstance(iface, dict):
|
||||||
|
continue
|
||||||
|
for addr in iface.get("ip-addresses") or []:
|
||||||
|
if not isinstance(addr, dict):
|
||||||
|
continue
|
||||||
|
if addr.get("ip-address-type") != "ipv4":
|
||||||
|
continue
|
||||||
|
ip = addr.get("ip-address")
|
||||||
|
if isinstance(ip, str) and ip and not ip.startswith("127."):
|
||||||
|
return ip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_lxc_ip(config_payload: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Parse a static IPv4 from an LXC ``net0`` config string (skip dhcp)."""
|
||||||
|
if not config_payload:
|
||||||
|
return None
|
||||||
|
net0 = config_payload.get("net0")
|
||||||
|
if not isinstance(net0, str):
|
||||||
|
return None
|
||||||
|
match = _LXC_IP_RE.search(net0)
|
||||||
|
return match.group(1) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_net_mac(config_payload: dict[str, Any] | None) -> str | None:
|
||||||
|
"""Parse the NIC MAC from a qemu/lxc ``net0`` config string (normalized).
|
||||||
|
|
||||||
|
Works agent-free for both guest kinds and for stopped guests — the sole
|
||||||
|
identity we can reliably cross-match against an ARP-scanned device.
|
||||||
|
"""
|
||||||
|
if not config_payload:
|
||||||
|
return None
|
||||||
|
net0 = config_payload.get("net0")
|
||||||
|
if not isinstance(net0, str):
|
||||||
|
return None
|
||||||
|
match = _NET_MAC_RE.search(net0)
|
||||||
|
return normalize_mac(match.group(1)) if match else None
|
||||||
|
|
||||||
|
|
||||||
|
def _host_node(raw: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable ``proxmox`` host node from a ``/nodes`` entry."""
|
||||||
|
name = raw.get("node")
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
ieee = f"pve-node-{name}"
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": name,
|
||||||
|
"type": "proxmox",
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"hostname": name,
|
||||||
|
"ip": raw.get("ip") or None,
|
||||||
|
"status": "online" if raw.get("status") == "online" else "offline",
|
||||||
|
"cpu_count": _int_or_none(raw.get("maxcpu")),
|
||||||
|
"ram_gb": _gb(raw.get("maxmem")),
|
||||||
|
"disk_gb": _gb(raw.get("maxdisk")),
|
||||||
|
"vendor": "Proxmox VE",
|
||||||
|
"model": None,
|
||||||
|
"parent_ieee": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_node(
|
||||||
|
raw: dict[str, Any], host_name: str, kind: str, ip: str | None, mac: str | None = None
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Build a homelable vm/lxc node from a ``/qemu`` or ``/lxc`` list entry."""
|
||||||
|
vmid = raw.get("vmid")
|
||||||
|
if vmid is None:
|
||||||
|
return None
|
||||||
|
ieee = f"pve-{host_name}-{vmid}"
|
||||||
|
node_type = _guest_type_to_homelable(kind)
|
||||||
|
name = raw.get("name") or f"{node_type}-{vmid}"
|
||||||
|
return {
|
||||||
|
"id": ieee,
|
||||||
|
"label": name,
|
||||||
|
"type": node_type,
|
||||||
|
"ieee_address": ieee,
|
||||||
|
"hostname": name,
|
||||||
|
"ip": ip,
|
||||||
|
"mac": mac,
|
||||||
|
"status": "online" if raw.get("status") == "running" else "offline",
|
||||||
|
"cpu_count": _int_or_none(raw.get("maxcpu") or raw.get("cpus")),
|
||||||
|
"ram_gb": _gb(raw.get("maxmem")),
|
||||||
|
"disk_gb": _gb(raw.get("maxdisk")),
|
||||||
|
"vendor": "Proxmox VE",
|
||||||
|
"model": kind.upper(),
|
||||||
|
"vmid": vmid,
|
||||||
|
"parent_ieee": f"pve-node-{host_name}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_proxmox_properties(node: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Build a NodeProperty list for a Proxmox device (specs + identity).
|
||||||
|
|
||||||
|
Icons match the existing hardware-property convention (``Cpu`` /
|
||||||
|
``MemoryStick`` / ``HardDrive``). All rows default ``visible=False`` — the
|
||||||
|
user opts in from the right panel, same as the mesh importers."""
|
||||||
|
props: list[dict[str, Any]] = []
|
||||||
|
vmid = node.get("vmid")
|
||||||
|
if vmid is not None:
|
||||||
|
props.append({"key": "VMID", "value": str(vmid), "icon": None, "visible": False})
|
||||||
|
if node.get("model"):
|
||||||
|
props.append({"key": "Kind", "value": node["model"], "icon": None, "visible": False})
|
||||||
|
if node.get("cpu_count") is not None:
|
||||||
|
props.append({"key": "CPU Cores", "value": str(node["cpu_count"]), "icon": "Cpu", "visible": False})
|
||||||
|
if node.get("ram_gb") is not None:
|
||||||
|
props.append({"key": "RAM", "value": f"{node['ram_gb']} GB", "icon": "MemoryStick", "visible": False})
|
||||||
|
if node.get("disk_gb") is not None:
|
||||||
|
props.append({"key": "Disk", "value": f"{node['disk_gb']} GB", "icon": "HardDrive", "visible": False})
|
||||||
|
props.append({"key": "Source", "value": "Proxmox VE", "icon": None, "visible": False})
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
def build_proxmox_cluster_links(nodes: list[dict[str, Any]]) -> list[tuple[str, str]]:
|
||||||
|
"""Chain host nodes (``type == 'proxmox'``) into cluster links.
|
||||||
|
|
||||||
|
Hosts from one import belong to the same cluster, so they are linked
|
||||||
|
host↔host (rendered as ``cluster`` edges via left/right handles, distinct
|
||||||
|
from the vertical host→guest ``virtual`` edges). Returns consecutive
|
||||||
|
``(source_ieee, target_ieee)`` pairs, or ``[]`` for a single host. Mirrors
|
||||||
|
the frontend ``buildProxmoxClusterEdges``.
|
||||||
|
"""
|
||||||
|
hosts = [n["ieee_address"] for n in nodes if n.get("type") == "proxmox" and n.get("ieee_address")]
|
||||||
|
if len(hosts) < 2:
|
||||||
|
return []
|
||||||
|
return [(hosts[i], hosts[i + 1]) for i in range(len(hosts) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_inventory(
|
||||||
|
hosts_raw: list[dict[str, Any]],
|
||||||
|
guests_by_host: dict[str, list[dict[str, Any]]],
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Assemble (nodes, edges) from fetched host + guest data.
|
||||||
|
|
||||||
|
``guests_by_host`` maps host name → list of already-normalized guest node
|
||||||
|
dicts. Edges are one host→guest link per guest (materialized as canvas
|
||||||
|
edges on approval, mirroring the zigbee/zwave link mechanism).
|
||||||
|
"""
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
edges: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
for raw in hosts_raw:
|
||||||
|
host = _host_node(raw)
|
||||||
|
if host is None or host["id"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(host["id"])
|
||||||
|
nodes.append(host)
|
||||||
|
|
||||||
|
for host_name, guests in guests_by_host.items():
|
||||||
|
host_ieee = f"pve-node-{host_name}"
|
||||||
|
for guest in guests:
|
||||||
|
if guest["id"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(guest["id"])
|
||||||
|
nodes.append(guest)
|
||||||
|
edges.append({"source": host_ieee, "target": guest["id"]})
|
||||||
|
|
||||||
|
return nodes, edges
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_json(client: httpx.AsyncClient, path: str) -> Any:
|
||||||
|
resp = await client.get(path)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("data")
|
||||||
|
|
||||||
|
|
||||||
|
async def _token_has_permissions(client: httpx.AsyncClient) -> bool:
|
||||||
|
"""True if the API token holds *any* ACL.
|
||||||
|
|
||||||
|
Proxmox list endpoints (``/qemu``, ``/lxc``) silently return an empty
|
||||||
|
``200`` when the token lacks ``VM.Audit`` — indistinguishable from a host
|
||||||
|
that genuinely has no guests. ``GET /access/permissions`` returns ``{}`` for
|
||||||
|
a token with no ACL at all, which is the common misconfiguration (a
|
||||||
|
privilege-separated token created without its own permission). Best-effort:
|
||||||
|
on any error assume permissions exist so we never block a valid import.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
perms = await _get_json(client, "/access/permissions")
|
||||||
|
except httpx.HTTPError:
|
||||||
|
return True
|
||||||
|
return bool(perms) if isinstance(perms, dict) else True
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_proxmox_inventory(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
token_id: str,
|
||||||
|
token_secret: str,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
"""Fetch hosts + VMs + LXC from a Proxmox VE host, return (nodes, edges).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: transport/DNS/TLS failures (sanitized message).
|
||||||
|
ValueError: malformed API response.
|
||||||
|
"""
|
||||||
|
base_url = f"https://{host}:{port}/api2/json"
|
||||||
|
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||||
|
guests_by_host: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=base_url,
|
||||||
|
headers=_auth_header(token_id, token_secret),
|
||||||
|
verify=verify_tls,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
hosts_raw = await _get_json(client, "/nodes")
|
||||||
|
if not isinstance(hosts_raw, list):
|
||||||
|
raise ValueError("Malformed /nodes response")
|
||||||
|
|
||||||
|
for host_entry in hosts_raw:
|
||||||
|
name = host_entry.get("node")
|
||||||
|
if not name or host_entry.get("status") != "online":
|
||||||
|
# Offline nodes can't be queried for guests; still shown as host.
|
||||||
|
continue
|
||||||
|
guests_by_host[name] = await _fetch_host_guests(client, name)
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise ConnectionError(_sanitize_proxmox_error(exc)) from exc
|
||||||
|
|
||||||
|
return _parse_inventory(hosts_raw, guests_by_host)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_host_guests(
|
||||||
|
client: httpx.AsyncClient, host_name: str
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch qemu + lxc guests for one host, resolving guest IPs best-effort."""
|
||||||
|
guests: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for kind in ("qemu", "lxc"):
|
||||||
|
try:
|
||||||
|
entries = await _get_json(client, f"/nodes/{host_name}/{kind}")
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Proxmox %s list failed for %s: %s", kind, host_name, exc)
|
||||||
|
continue
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
continue
|
||||||
|
for raw in entries:
|
||||||
|
ip, mac = await _resolve_guest_net(client, host_name, kind, raw)
|
||||||
|
node = _guest_node(raw, host_name, kind, ip, mac)
|
||||||
|
if node:
|
||||||
|
guests.append(node)
|
||||||
|
|
||||||
|
return guests
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_guest_net(
|
||||||
|
client: httpx.AsyncClient, host_name: str, kind: str, raw: dict[str, Any]
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Best-effort (ip, mac) for a guest. Never raises.
|
||||||
|
|
||||||
|
- MAC comes from the guest ``/config`` net0 line for both kinds (agent-free,
|
||||||
|
works for stopped guests) — the cross-source dedup key.
|
||||||
|
- IP: qemu → guest agent (running only); lxc → static net0 config.
|
||||||
|
Partial results are returned even if a later call fails (e.g. config MAC is
|
||||||
|
kept when the qemu agent call errors).
|
||||||
|
"""
|
||||||
|
vmid = raw.get("vmid")
|
||||||
|
if vmid is None:
|
||||||
|
return None, None
|
||||||
|
ip: str | None = None
|
||||||
|
mac: str | None = None
|
||||||
|
try:
|
||||||
|
if kind == "qemu":
|
||||||
|
config = await _get_json(client, f"/nodes/{host_name}/qemu/{vmid}/config")
|
||||||
|
mac = _extract_net_mac(config)
|
||||||
|
if raw.get("status") == "running":
|
||||||
|
data = await _get_json(
|
||||||
|
client, f"/nodes/{host_name}/qemu/{vmid}/agent/network-get-interfaces"
|
||||||
|
)
|
||||||
|
ip = _extract_qemu_ip(data)
|
||||||
|
else:
|
||||||
|
config = await _get_json(client, f"/nodes/{host_name}/lxc/{vmid}/config")
|
||||||
|
ip = _extract_lxc_ip(config)
|
||||||
|
mac = _extract_net_mac(config)
|
||||||
|
except httpx.HTTPError:
|
||||||
|
# Guest agent not installed / container stopped / no perms. Keep whatever
|
||||||
|
# was resolved before the failure.
|
||||||
|
pass
|
||||||
|
return ip, mac
|
||||||
|
|
||||||
|
|
||||||
|
async def test_proxmox_connection(
|
||||||
|
host: str,
|
||||||
|
port: int,
|
||||||
|
token_id: str,
|
||||||
|
token_secret: str,
|
||||||
|
verify_tls: bool = True,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Quick reachability + auth check via ``GET /version``.
|
||||||
|
|
||||||
|
Returns (connected, message). Never raises credentials outward.
|
||||||
|
"""
|
||||||
|
base_url = f"https://{host}:{port}/api2/json"
|
||||||
|
timeout = httpx.Timeout(_READ_TIMEOUT, connect=_CONNECT_TIMEOUT)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=base_url,
|
||||||
|
headers=_auth_header(token_id, token_secret),
|
||||||
|
verify=verify_tls,
|
||||||
|
timeout=timeout,
|
||||||
|
) as client:
|
||||||
|
data = await _get_json(client, "/version")
|
||||||
|
has_perms = await _token_has_permissions(client)
|
||||||
|
version = (data or {}).get("version", "?") if isinstance(data, dict) else "?"
|
||||||
|
message = f"Connected to Proxmox VE {version}"
|
||||||
|
if not has_perms:
|
||||||
|
message += (
|
||||||
|
" — warning: this API token has no permissions, so VMs and LXC "
|
||||||
|
"will not be visible. Assign the PVEAuditor role at path '/' to the "
|
||||||
|
"token in Proxmox (Datacenter → Permissions → API Token Permission)."
|
||||||
|
)
|
||||||
|
return True, message
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return False, _sanitize_proxmox_error(exc)
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface a safe message, log the rest
|
||||||
|
logger.exception("Unexpected error during Proxmox connection test")
|
||||||
|
return False, _sanitize_proxmox_error(exc)
|
||||||
@@ -14,9 +14,12 @@ from typing import Any
|
|||||||
from sqlalchemy import or_, select
|
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 Node, 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.http_probe import probe_open_ports
|
||||||
|
from app.services.mac_utils import normalize_mac
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -238,6 +241,14 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
|
|||||||
"""
|
"""
|
||||||
Phase 2 — single-IP port scan with service detection.
|
Phase 2 — single-IP port scan with service detection.
|
||||||
Runs in a thread (blocking). Returns the host dict enriched with open_ports.
|
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"]
|
ip = host_dict["ip"]
|
||||||
logger.info("[Phase 2] Scanning %s ...", ip)
|
logger.info("[Phase 2] Scanning %s ...", ip)
|
||||||
@@ -246,48 +257,76 @@ def _nmap_scan_single(host_dict: dict[str, Any], port_spec: str = _EXTRA_PORTS)
|
|||||||
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
logger.warning("[Phase 2] nmap not available, skipping %s", ip)
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
is_root = os.geteuid() == 0
|
# -sS (SYN) needs root; -sT (connect) works without it. nmap auto-selects
|
||||||
if is_root:
|
# -sT without root but being explicit avoids edge cases.
|
||||||
# SYN scan + version detection (fastest, most accurate)
|
scan_type = "-sS" if os.geteuid() == 0 else "-sT"
|
||||||
scan_args = f"-sS -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
|
||||||
else:
|
|
||||||
# TCP connect scan (-sT) — no raw sockets needed, works without root.
|
|
||||||
# nmap auto-selects -sT without root but being explicit avoids edge cases.
|
|
||||||
scan_args = f"-sT -sV --open -T4 -Pn --host-timeout 60s -p {port_spec}"
|
|
||||||
|
|
||||||
logger.debug("[Phase 2] %s args: %s", ip, scan_args)
|
# --- Pass A: port discovery (no -sV, no host-timeout) ---
|
||||||
nm = nmap.PortScanner()
|
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:
|
try:
|
||||||
nm.scan(hosts=ip, arguments=scan_args)
|
nm_disc.scan(hosts=ip, arguments=discovery_args)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("[Phase 2] nmap FAILED for %s (%s: %s) — skipping port scan", ip, type(exc).__name__, exc)
|
logger.warning("[Phase 2] nmap discovery FAILED for %s (%s: %s) — skipping port scan",
|
||||||
|
ip, type(exc).__name__, exc)
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
all_scanned = nm.all_hosts()
|
if ip not in nm_disc.all_hosts():
|
||||||
logger.debug("[Phase 2] %s — nmap returned %d host(s) in results", ip, len(all_scanned))
|
|
||||||
if ip not in all_scanned:
|
|
||||||
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
logger.info("[Phase 2] %s — no open ports found (all closed/filtered or nmap had no results)", ip)
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
open_ports = []
|
open_ports = []
|
||||||
for proto in nm[ip].all_protocols():
|
for proto in nm_disc[ip].all_protocols():
|
||||||
for port, info in nm[ip][proto].items():
|
for port, info in nm_disc[ip][proto].items():
|
||||||
if info["state"] == "open":
|
if info["state"] == "open":
|
||||||
banner = (info.get("product", "") + " " + info.get("version", "")).strip()
|
open_ports.append({"port": port, "protocol": proto, "banner": ""})
|
||||||
open_ports.append({"port": port, "protocol": proto, "banner": banner})
|
|
||||||
|
|
||||||
if open_ports:
|
if not open_ports:
|
||||||
port_summary = ", ".join(
|
|
||||||
f"{p['port']}/{p['protocol']} ({p['banner'] or 'unknown'})" for p in open_ports
|
|
||||||
)
|
|
||||||
logger.info("[Phase 2] %s — %d open port(s): %s", ip, len(open_ports), port_summary)
|
|
||||||
else:
|
|
||||||
logger.info("[Phase 2] %s — 0 open ports detected", ip)
|
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
|
host_dict["open_ports"] = open_ports
|
||||||
if not host_dict["mac"]:
|
if not host_dict["mac"]:
|
||||||
host_dict["mac"] = nm[ip].get("addresses", {}).get("mac")
|
host_dict["mac"] = ver_mac or nm_disc[ip].get("addresses", {}).get("mac")
|
||||||
host_dict["os"] = _extract_os(nm, ip)
|
host_dict["os"] = ver_os
|
||||||
return host_dict
|
return host_dict
|
||||||
|
|
||||||
|
|
||||||
@@ -522,16 +561,21 @@ async def run_scan(
|
|||||||
ip, open_ports, verify_tls=deep_scan.verify_tls
|
ip, open_ports, verify_tls=deep_scan.verify_tls
|
||||||
)
|
)
|
||||||
|
|
||||||
|
norm_mac = normalize_mac(host.get("mac"))
|
||||||
services = fingerprint_ports(open_ports)
|
services = fingerprint_ports(open_ports)
|
||||||
suggested_type = suggest_node_type(open_ports, host.get("mac"))
|
suggested_type = suggest_node_type(open_ports, norm_mac)
|
||||||
|
|
||||||
# One inventory row per device (by IP). Match across pending AND
|
# One inventory row per device. Match by IP OR MAC across pending AND
|
||||||
# approved so a re-scan of an already-approved device refreshes its
|
# approved so a re-scan refreshes the existing row instead of spawning
|
||||||
# row instead of spawning a fresh "pending" duplicate. Hidden rows
|
# a duplicate — and so a device previously imported from Proxmox (which
|
||||||
# are already skipped above.
|
# 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(
|
existing_rows = (await db.execute(
|
||||||
select(PendingDevice)
|
select(PendingDevice)
|
||||||
.where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
|
.where(or_(*match_cond), PendingDevice.status != "hidden")
|
||||||
.order_by(PendingDevice.discovered_at)
|
.order_by(PendingDevice.discovered_at)
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|
||||||
@@ -543,32 +587,41 @@ async def run_scan(
|
|||||||
for dup in existing_rows:
|
for dup in existing_rows:
|
||||||
if dup is not keep:
|
if dup is not keep:
|
||||||
await db.delete(dup)
|
await db.delete(dup)
|
||||||
keep.mac = host.get("mac") or keep.mac
|
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.hostname = host.get("hostname") or keep.hostname
|
||||||
keep.os = host.get("os") or keep.os
|
keep.os = host.get("os") or keep.os
|
||||||
keep.services = services
|
keep.services = services
|
||||||
keep.suggested_type = suggested_type
|
# 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.
|
# status preserved — an approved device stays approved.
|
||||||
else:
|
else:
|
||||||
db.add(PendingDevice(
|
db.add(PendingDevice(
|
||||||
ip=ip,
|
ip=ip,
|
||||||
mac=host.get("mac"),
|
mac=norm_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_source=discovery_source,
|
||||||
|
discovery_sources=[discovery_source],
|
||||||
))
|
))
|
||||||
devices_found += 1
|
devices_found += 1
|
||||||
|
|
||||||
# Stamp last_scan on any canvas node that matches this device by IP
|
# Stamp last_scan on any canvas node that matches this device by IP
|
||||||
# (or MAC, when known) so the inventory shows when the scanner last
|
# (or MAC, when known) so the inventory shows when the scanner last
|
||||||
# observed it. Matches across designs.
|
# observed it. Match both the normalized and raw MAC so a legacy
|
||||||
host_mac = host.get("mac")
|
# canvas node whose mac predates normalization still matches. Across
|
||||||
|
# designs.
|
||||||
node_match = [Node.ip == ip]
|
node_match = [Node.ip == ip]
|
||||||
if host_mac:
|
for m in {norm_mac, host.get("mac")}:
|
||||||
node_match.append(Node.mac == host_mac)
|
if m:
|
||||||
|
node_match.append(Node.mac == m)
|
||||||
matching_nodes = (await db.execute(
|
matching_nodes = (await db.execute(
|
||||||
select(Node).where(or_(*node_match))
|
select(Node).where(or_(*node_match))
|
||||||
)).scalars().all()
|
)).scalars().all()
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -44,10 +44,7 @@ async def client(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers(client):
|
async def headers(client: AsyncClient):
|
||||||
"""Returns a coroutine that logs in and returns auth headers."""
|
"""Authenticated Bearer headers for the default admin test user."""
|
||||||
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
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"""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"}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""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}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"""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)
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
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}
|
||||||
|
|
||||||
@@ -51,6 +44,76 @@ 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):
|
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload(label="Router", type="router")
|
n1 = node_payload(label="Router", type="router")
|
||||||
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)
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
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}
|
||||||
|
|
||||||
@@ -100,6 +93,118 @@ async def test_list_returns_created_designs_ordered(client: AsyncClient, headers
|
|||||||
assert ids == [a["id"], b["id"]]
|
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 ────────────────────────────────────────────────────────────────────
|
# ── update ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
async def test_update_design_renames(client: AsyncClient, headers: dict):
|
||||||
|
|||||||
+174
-7
@@ -2,13 +2,6 @@ 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()
|
||||||
@@ -91,6 +84,70 @@ 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"})
|
||||||
@@ -163,3 +220,113 @@ 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"
|
||||||
|
|||||||
@@ -82,10 +82,9 @@ async def test_liveview_does_not_require_jwt(client: AsyncClient):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
async def test_liveview_returns_saved_canvas(client: AsyncClient, headers):
|
||||||
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
"""Canvas saved via POST /canvas/save appears in liveview response."""
|
||||||
settings.liveview_key = "test-key"
|
settings.liveview_key = "test-key"
|
||||||
headers = await auth_headers()
|
|
||||||
|
|
||||||
# Save a canvas with one node
|
# Save a canvas with one node
|
||||||
payload = {
|
payload = {
|
||||||
@@ -115,10 +114,9 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
|||||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, headers):
|
||||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||||
settings.liveview_key = "test-key"
|
settings.liveview_key = "test-key"
|
||||||
headers = await auth_headers()
|
|
||||||
payload = {
|
payload = {
|
||||||
"nodes": [],
|
"nodes": [],
|
||||||
"edges": [],
|
"edges": [],
|
||||||
@@ -159,9 +157,8 @@ async def test_liveview_config_requires_auth(client: AsyncClient):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, auth_headers):
|
async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, headers):
|
||||||
settings.liveview_key = "share-me"
|
settings.liveview_key = "share-me"
|
||||||
headers = await auth_headers()
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
body = res.json()
|
body = res.json()
|
||||||
@@ -169,18 +166,16 @@ async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, aut
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_config_disabled_hides_key(client: AsyncClient, auth_headers):
|
async def test_liveview_config_disabled_hides_key(client: AsyncClient, headers):
|
||||||
settings.liveview_key = None
|
settings.liveview_key = None
|
||||||
headers = await auth_headers()
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"enabled": False, "key": None}
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
|
async def test_liveview_config_empty_key_disabled(client: AsyncClient, headers):
|
||||||
settings.liveview_key = ""
|
settings.liveview_key = ""
|
||||||
headers = await auth_headers()
|
|
||||||
res = await client.get("/api/v1/liveview/config", headers=headers)
|
res = await client.get("/api/v1/liveview/config", headers=headers)
|
||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json() == {"enabled": False, "key": None}
|
assert res.json() == {"enabled": False, "key": None}
|
||||||
@@ -189,10 +184,9 @@ async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_head
|
|||||||
# ── design_id selects which canvas is rendered ───────────────────────────────
|
# ── design_id selects which canvas is rendered ───────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_liveview_design_id_selects_canvas(client: AsyncClient, auth_headers):
|
async def test_liveview_design_id_selects_canvas(client: AsyncClient, headers):
|
||||||
"""?design_id=<id> renders that design's canvas, not the first one."""
|
"""?design_id=<id> renders that design's canvas, not the first one."""
|
||||||
settings.liveview_key = "test-key"
|
settings.liveview_key = "test-key"
|
||||||
headers = await auth_headers()
|
|
||||||
|
|
||||||
# Create two designs
|
# Create two designs
|
||||||
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Unit tests for MAC normalization (the cross-source dedup key)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.services.discovery_sources import add_source
|
||||||
|
from app.services.mac_utils import normalize_mac
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mac_lowercases_and_unifies_separators() -> None:
|
||||||
|
assert normalize_mac("BC:24:11:AA:BB:CC") == "bc:24:11:aa:bb:cc"
|
||||||
|
assert normalize_mac("bc-24-11-aa-bb-cc") == "bc:24:11:aa:bb:cc"
|
||||||
|
assert normalize_mac(" BC:24:11:AA:BB:CC ") == "bc:24:11:aa:bb:cc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_mac_blank_is_none() -> None:
|
||||||
|
assert normalize_mac(None) is None
|
||||||
|
assert normalize_mac("") is None
|
||||||
|
assert normalize_mac(" ") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_source_unions_without_duplicates() -> None:
|
||||||
|
assert add_source(None, "arp") == ["arp"]
|
||||||
|
assert add_source(["arp"], "proxmox") == ["arp", "proxmox"]
|
||||||
|
assert add_source(["arp", "proxmox"], "proxmox") == ["arp", "proxmox"]
|
||||||
|
assert add_source(["arp"], None) == ["arp"]
|
||||||
|
# Drops falsy members already present.
|
||||||
|
assert add_source(["arp", ""], "proxmox") == ["arp", "proxmox"]
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.api.routes import media
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def media_dir(tmp_path, monkeypatch):
|
||||||
|
"""Point uploads at a temp folder for the duration of a test."""
|
||||||
|
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
|
||||||
|
return await client.post(
|
||||||
|
"/api/v1/media/upload",
|
||||||
|
files={"file": (name, data, content_type)},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_requires_auth(client, media_dir):
|
||||||
|
res = await _upload(client, headers={})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_stores_file_and_returns_url(client, headers, media_dir):
|
||||||
|
res = await _upload(client, headers)
|
||||||
|
assert res.status_code == 200
|
||||||
|
body = res.json()
|
||||||
|
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
|
||||||
|
# File written to disk with the server-generated name (client name ignored).
|
||||||
|
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
|
||||||
|
assert body["filename"] != "plan.png"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejects_unsupported_type(client, headers, media_dir):
|
||||||
|
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
|
||||||
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejects_content_type_magic_mismatch(client, headers, media_dir):
|
||||||
|
# Claims PNG but bytes are not a PNG.
|
||||||
|
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
|
||||||
|
assert res.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_rejects_oversize(client, headers, media_dir, monkeypatch):
|
||||||
|
monkeypatch.setattr(media, "MAX_BYTES", 8)
|
||||||
|
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
|
||||||
|
assert res.status_code == 413
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_serves_uploaded_file(client, headers, media_dir):
|
||||||
|
up = await _upload(client, headers)
|
||||||
|
res = await client.get(up.json()["url"]) # public, no auth
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.content == PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_rejects_bad_filename(client, media_dir):
|
||||||
|
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_rejects_bad_filename(client, headers, media_dir):
|
||||||
|
res = await client.delete("/api/v1/media/..%2f..%2fetc%2fpasswd", headers=headers)
|
||||||
|
assert res.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_requires_auth_and_removes_file(client, headers, media_dir):
|
||||||
|
up = await _upload(client, headers)
|
||||||
|
filename = up.json()["filename"]
|
||||||
|
|
||||||
|
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
|
||||||
|
assert (media_dir / filename).exists()
|
||||||
|
|
||||||
|
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
|
||||||
|
assert res.status_code == 204
|
||||||
|
assert not (media_dir / filename).exists()
|
||||||
|
assert (await client.get(up.json()["url"])).status_code == 404
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""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
|
||||||
+118
-8
@@ -1,14 +1,6 @@
|
|||||||
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
|
||||||
@@ -99,6 +91,47 @@ async def test_create_node_respects_explicit_design_id(client: AsyncClient, head
|
|||||||
assert res.json()["design_id"] == second_id
|
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):
|
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
|
# No designs exist yet: fallback can't invent one, so design_id stays null
|
||||||
# rather than erroring.
|
# rather than erroring.
|
||||||
@@ -253,3 +286,80 @@ async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
|
|||||||
)
|
)
|
||||||
assert create.status_code == 201
|
assert create.status_code == 201
|
||||||
assert create.json()["properties"] == props
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""Unit tests for the Proxmox VE import service (parsing, props, sanitizer)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services import proxmox_service as svc
|
||||||
|
|
||||||
|
|
||||||
|
def test_gb_conversion() -> None:
|
||||||
|
assert svc._gb(1024 ** 3) == 1.0
|
||||||
|
assert svc._gb(2 * 1024 ** 3) == 2.0
|
||||||
|
assert svc._gb(0) is None
|
||||||
|
assert svc._gb(None) is None
|
||||||
|
assert svc._gb("nope") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_qemu_ip_skips_loopback() -> None:
|
||||||
|
payload = {
|
||||||
|
"result": [
|
||||||
|
{"name": "lo", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "127.0.0.1"}]},
|
||||||
|
{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "192.168.1.20"}]},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
assert svc._extract_qemu_ip(payload) == "192.168.1.20"
|
||||||
|
assert svc._extract_qemu_ip(None) is None
|
||||||
|
assert svc._extract_qemu_ip({"result": []}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_lxc_ip_parses_net0_static() -> None:
|
||||||
|
cfg = {"net0": "name=eth0,bridge=vmbr0,ip=192.168.1.30/24,gw=192.168.1.1"}
|
||||||
|
assert svc._extract_lxc_ip(cfg) == "192.168.1.30"
|
||||||
|
# DHCP → no static IP
|
||||||
|
assert svc._extract_lxc_ip({"net0": "name=eth0,bridge=vmbr0,ip=dhcp"}) is None
|
||||||
|
assert svc._extract_lxc_ip(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_net_mac_parses_qemu_and_lxc_forms() -> None:
|
||||||
|
# qemu: "virtio=<MAC>,bridge=.."
|
||||||
|
assert svc._extract_net_mac({"net0": "virtio=BC:24:11:AA:BB:CC,bridge=vmbr0"}) == "bc:24:11:aa:bb:cc"
|
||||||
|
# lxc: "..,hwaddr=<MAC>,.."
|
||||||
|
assert (
|
||||||
|
svc._extract_net_mac({"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:11:22:33,ip=dhcp"})
|
||||||
|
== "bc:24:11:11:22:33"
|
||||||
|
)
|
||||||
|
# No MAC / missing net0 / None → None
|
||||||
|
assert svc._extract_net_mac({"net0": "name=eth0,ip=dhcp"}) is None
|
||||||
|
assert svc._extract_net_mac({}) is None
|
||||||
|
assert svc._extract_net_mac(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_inventory_captures_guest_mac_from_config() -> None:
|
||||||
|
"""Guests carry a normalized NIC MAC read agent-free from their config."""
|
||||||
|
async def fake_get_json(client, path: str):
|
||||||
|
if path == "/nodes":
|
||||||
|
return [{"node": "pve1", "status": "online", "maxcpu": 8}]
|
||||||
|
if path == "/nodes/pve1/qemu":
|
||||||
|
return [{"vmid": 101, "name": "web", "status": "stopped"}] # stopped: no agent IP
|
||||||
|
if path == "/nodes/pve1/lxc":
|
||||||
|
return [{"vmid": 200, "name": "db", "status": "running"}]
|
||||||
|
if path == "/nodes/pve1/qemu/101/config":
|
||||||
|
return {"net0": "virtio=AA:BB:CC:DD:EE:FF,bridge=vmbr0"}
|
||||||
|
if path == "/nodes/pve1/lxc/200/config":
|
||||||
|
return {"net0": "name=eth0,bridge=vmbr0,hwaddr=11:22:33:44:55:66,ip=10.0.0.6/24"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||||
|
nodes, _ = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
||||||
|
|
||||||
|
vm = next(n for n in nodes if n["type"] == "vm")
|
||||||
|
assert vm["mac"] == "aa:bb:cc:dd:ee:ff" # captured even though VM is stopped (no IP)
|
||||||
|
assert vm["ip"] is None
|
||||||
|
ct = next(n for n in nodes if n["type"] == "lxc")
|
||||||
|
assert ct["mac"] == "11:22:33:44:55:66"
|
||||||
|
assert ct["ip"] == "10.0.0.6"
|
||||||
|
|
||||||
|
|
||||||
|
def test_host_and_guest_node_mapping() -> None:
|
||||||
|
host = svc._host_node({"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3, "maxdisk": 500 * 1024 ** 3})
|
||||||
|
assert host["type"] == "proxmox"
|
||||||
|
assert host["ieee_address"] == "pve-node-pve1"
|
||||||
|
assert host["cpu_count"] == 8
|
||||||
|
assert host["ram_gb"] == 16.0
|
||||||
|
assert host["status"] == "online"
|
||||||
|
assert host["parent_ieee"] is None
|
||||||
|
|
||||||
|
vm = svc._guest_node({"vmid": 101, "name": "web", "status": "running", "maxcpu": 2, "maxmem": 2 * 1024 ** 3, "maxdisk": 32 * 1024 ** 3}, "pve1", "qemu", "10.0.0.5")
|
||||||
|
assert vm["type"] == "vm"
|
||||||
|
assert vm["ieee_address"] == "pve-pve1-101"
|
||||||
|
assert vm["ip"] == "10.0.0.5"
|
||||||
|
assert vm["status"] == "online"
|
||||||
|
assert vm["parent_ieee"] == "pve-node-pve1"
|
||||||
|
|
||||||
|
ct = svc._guest_node({"vmid": 200, "name": "db", "status": "stopped"}, "pve1", "lxc", None)
|
||||||
|
assert ct["type"] == "lxc"
|
||||||
|
assert ct["status"] == "offline"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_properties_includes_specs() -> None:
|
||||||
|
node = {"vmid": 101, "model": "QEMU", "cpu_count": 4, "ram_gb": 8.0, "disk_gb": 40.0}
|
||||||
|
props = svc.build_proxmox_properties(node)
|
||||||
|
keys = {p["key"] for p in props}
|
||||||
|
assert {"VMID", "CPU Cores", "RAM", "Disk", "Source"} <= keys
|
||||||
|
assert all(p["visible"] is False for p in props)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_inventory_builds_host_guest_edges() -> None:
|
||||||
|
hosts = [{"node": "pve1", "status": "online", "maxcpu": 4}]
|
||||||
|
guests = {"pve1": [svc._guest_node({"vmid": 101, "name": "web", "status": "running"}, "pve1", "qemu", None)]}
|
||||||
|
nodes, edges = svc._parse_inventory(hosts, guests)
|
||||||
|
assert len(nodes) == 2
|
||||||
|
assert edges == [{"source": "pve-node-pve1", "target": "pve-pve1-101"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cluster_links_chains_hosts() -> None:
|
||||||
|
nodes = [
|
||||||
|
svc._host_node({"node": "pve-a", "status": "online"}),
|
||||||
|
svc._guest_node({"vmid": 101, "status": "running"}, "pve-a", "qemu", None),
|
||||||
|
svc._host_node({"node": "pve-b", "status": "online"}),
|
||||||
|
svc._host_node({"node": "pve-c", "status": "online"}),
|
||||||
|
]
|
||||||
|
pairs = svc.build_proxmox_cluster_links(nodes)
|
||||||
|
assert pairs == [("pve-node-pve-a", "pve-node-pve-b"), ("pve-node-pve-b", "pve-node-pve-c")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_cluster_links_single_host_is_not_a_cluster() -> None:
|
||||||
|
nodes = [svc._host_node({"node": "pve-a", "status": "online"})]
|
||||||
|
assert svc.build_proxmox_cluster_links(nodes) == []
|
||||||
|
# Guests alone never form a cluster.
|
||||||
|
guest = svc._guest_node({"vmid": 1, "status": "running"}, "pve-a", "qemu", None)
|
||||||
|
assert svc.build_proxmox_cluster_links([guest]) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_error_hides_credentials() -> None:
|
||||||
|
exc = httpx.HTTPStatusError(
|
||||||
|
"boom", request=httpx.Request("GET", "https://h/api2/json"),
|
||||||
|
response=httpx.Response(401),
|
||||||
|
)
|
||||||
|
msg = svc._sanitize_proxmox_error(exc)
|
||||||
|
assert "token" not in msg.lower() or "check the api token" in msg.lower()
|
||||||
|
assert "Authentication failed" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_inventory_happy_path() -> None:
|
||||||
|
async def fake_get_json(client, path: str):
|
||||||
|
if path == "/nodes":
|
||||||
|
return [{"node": "pve1", "status": "online", "maxcpu": 8, "maxmem": 16 * 1024 ** 3}]
|
||||||
|
if path == "/nodes/pve1/qemu":
|
||||||
|
return [{"vmid": 101, "name": "web", "status": "running", "maxmem": 2 * 1024 ** 3}]
|
||||||
|
if path == "/nodes/pve1/lxc":
|
||||||
|
return [{"vmid": 200, "name": "db", "status": "stopped"}]
|
||||||
|
if path.endswith("/agent/network-get-interfaces"):
|
||||||
|
return {"result": [{"name": "eth0", "ip-addresses": [{"ip-address-type": "ipv4", "ip-address": "10.0.0.5"}]}]}
|
||||||
|
if path.endswith("/config"):
|
||||||
|
return {"net0": "name=eth0,ip=10.0.0.6/24"}
|
||||||
|
return None
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||||
|
nodes, edges = await svc.fetch_proxmox_inventory("h", 8006, "u@pam!t", "sec")
|
||||||
|
|
||||||
|
by_type = {n["type"] for n in nodes}
|
||||||
|
assert by_type == {"proxmox", "vm", "lxc"}
|
||||||
|
vm = next(n for n in nodes if n["type"] == "vm")
|
||||||
|
assert vm["ip"] == "10.0.0.5"
|
||||||
|
ct = next(n for n in nodes if n["type"] == "lxc")
|
||||||
|
assert ct["ip"] == "10.0.0.6"
|
||||||
|
assert len(edges) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_returns_message() -> None:
|
||||||
|
async def fake_get_json(client, path: str):
|
||||||
|
if path == "/access/permissions":
|
||||||
|
return {"/": {"VM.Audit": 1}} # token has an ACL
|
||||||
|
return {"version": "8.2.2"}
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||||
|
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||||
|
assert ok is True
|
||||||
|
assert "8.2.2" in msg
|
||||||
|
assert "warning" not in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_test_connection_warns_when_token_has_no_permissions() -> None:
|
||||||
|
async def fake_get_json(client, path: str):
|
||||||
|
if path == "/access/permissions":
|
||||||
|
return {} # privilege-separated token with no effective ACL
|
||||||
|
return {"version": "8.4.19"}
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||||
|
ok, msg = await svc.test_proxmox_connection("h", 8006, "u@pam!t", "sec")
|
||||||
|
# Auth still succeeds; the message flags the permission gap.
|
||||||
|
assert ok is True
|
||||||
|
assert "8.4.19" in msg
|
||||||
|
assert "PVEAuditor" in msg
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_has_permissions_treats_empty_as_no_perms() -> None:
|
||||||
|
async def fake_get_json(client, path: str):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=fake_get_json)):
|
||||||
|
assert await svc._token_has_permissions(object()) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_has_permissions_assumes_ok_on_error() -> None:
|
||||||
|
async def boom(client, path: str):
|
||||||
|
raise httpx.ConnectError("nope")
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_json", new=AsyncMock(side_effect=boom)):
|
||||||
|
# Never block a valid import on a permissions-probe failure.
|
||||||
|
assert await svc._token_has_permissions(object()) is True
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,128 @@ def test_nmap_scan_single_returns_host_unchanged_when_no_results():
|
|||||||
assert result["mac"] == "34:94:54:aa:bb:cc" # preserved from Phase 1
|
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
|
# _nmap_scan
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -577,6 +699,44 @@ async def test_run_scan_mdns_only_device_added(mem_db):
|
|||||||
assert device.discovery_source == "mdns"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
|
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."""
|
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ 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_proxmox_sync,
|
||||||
_run_service_checks,
|
_run_service_checks,
|
||||||
_run_status_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_service_checks_enabled,
|
||||||
|
set_zigbee_sync_enabled,
|
||||||
|
set_zwave_sync_enabled,
|
||||||
start_scheduler,
|
start_scheduler,
|
||||||
stop_scheduler,
|
stop_scheduler,
|
||||||
)
|
)
|
||||||
@@ -148,6 +159,9 @@ def test_scheduler_uses_settings_interval():
|
|||||||
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.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
|
||||||
@@ -156,7 +170,13 @@ 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()
|
||||||
@@ -245,7 +265,286 @@ def test_start_scheduler_adds_service_job_when_enabled():
|
|||||||
mock_settings.status_checker_interval = 60
|
mock_settings.status_checker_interval = 60
|
||||||
mock_settings.service_check_enabled = True
|
mock_settings.service_check_enabled = True
|
||||||
mock_settings.service_check_interval = 300
|
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()
|
start_scheduler()
|
||||||
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||||
assert "status_checks" in job_ids
|
assert "status_checks" in job_ids
|
||||||
assert "service_checks" in job_ids
|
assert "service_checks" in job_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Proxmox auto-sync job
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_set_proxmox_sync_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.proxmox_sync_interval = 3600
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_proxmox_sync_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_proxmox_sync_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("proxmox_sync")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_proxmox_sync_rejects_short_interval():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reschedule_proxmox_sync(60)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_proxmox_sync_skips_when_disabled():
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.proxmox_sync_enabled = False
|
||||||
|
# Must return before importing/fetching anything.
|
||||||
|
await _run_proxmox_sync()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_proxmox_sync_skips_when_no_token():
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.proxmox_sync_enabled = True
|
||||||
|
mock_settings.proxmox_host = "pve"
|
||||||
|
mock_settings.proxmox_token_id = ""
|
||||||
|
mock_settings.proxmox_token_secret = ""
|
||||||
|
await _run_proxmox_sync() # no exception, no fetch
|
||||||
|
|
||||||
|
|
||||||
|
def _proxmox_settings(mock_settings):
|
||||||
|
mock_settings.proxmox_sync_enabled = True
|
||||||
|
mock_settings.proxmox_host = "pve"
|
||||||
|
mock_settings.proxmox_port = 8006
|
||||||
|
mock_settings.proxmox_token_id = "root@pam!tok"
|
||||||
|
mock_settings.proxmox_token_secret = "secret"
|
||||||
|
mock_settings.proxmox_verify_tls = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_proxmox_sync_records_scan_run(mem_db):
|
||||||
|
"""Auto-sync must create a ScanRun (kind=proxmox) so it shows in Scan
|
||||||
|
history, then delegate to the shared background import with the run id."""
|
||||||
|
from app.db.models import ScanRun
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings,
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||||
|
patch(
|
||||||
|
"app.api.routes.proxmox._background_proxmox_import",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_bg,
|
||||||
|
):
|
||||||
|
_proxmox_settings(mock_settings)
|
||||||
|
await _run_proxmox_sync()
|
||||||
|
|
||||||
|
# A ScanRun row exists — the missing scan-history trace.
|
||||||
|
async with mem_db() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||||
|
assert run.kind == "proxmox"
|
||||||
|
assert run.ranges == ["pve:8006"]
|
||||||
|
|
||||||
|
# Delegated to the same background flow the manual /sync-now uses, passing
|
||||||
|
# the run id + env connection settings.
|
||||||
|
mock_bg.assert_awaited_once_with(
|
||||||
|
run.id, "pve", 8006, "root@pam!tok", "secret", False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_set_zigbee_sync_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.zigbee_sync_interval = 3600
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_zigbee_sync_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_zigbee_sync_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_zwave_sync_enabled_adds_and_removes_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched), \
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.zwave_sync_interval = 3600
|
||||||
|
mock_sched.get_job.return_value = None
|
||||||
|
set_zwave_sync_enabled(True)
|
||||||
|
mock_sched.add_job.assert_called_once()
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
set_zwave_sync_enabled(False)
|
||||||
|
mock_sched.remove_job.assert_called_once_with("zwave_sync")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_zigbee_sync_rejects_short_interval():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reschedule_zigbee_sync(60)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_zwave_sync_rejects_short_interval():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reschedule_zwave_sync(60)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_zigbee_sync_skips_when_disabled():
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.zigbee_sync_enabled = False
|
||||||
|
await _run_zigbee_sync() # returns before importing/fetching anything
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_zigbee_sync_skips_when_no_host():
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.zigbee_sync_enabled = True
|
||||||
|
mock_settings.zigbee_mqtt_host = ""
|
||||||
|
await _run_zigbee_sync() # no exception, no fetch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_zwave_sync_skips_when_no_host():
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings:
|
||||||
|
mock_settings.zwave_sync_enabled = True
|
||||||
|
mock_settings.zwave_mqtt_host = ""
|
||||||
|
await _run_zwave_sync()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_zigbee_sync_records_scan_run(mem_db):
|
||||||
|
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
|
||||||
|
then delegate to the shared background import with the run id + env payload."""
|
||||||
|
from app.db.models import ScanRun
|
||||||
|
from app.schemas.zigbee import ZigbeeImportRequest
|
||||||
|
|
||||||
|
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings,
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||||
|
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
|
||||||
|
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
|
||||||
|
):
|
||||||
|
mock_settings.zigbee_sync_enabled = True
|
||||||
|
mock_settings.zigbee_mqtt_host = "broker"
|
||||||
|
mock_settings.zigbee_mqtt_port = 1883
|
||||||
|
await _run_zigbee_sync()
|
||||||
|
|
||||||
|
async with mem_db() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||||
|
assert run.kind == "zigbee"
|
||||||
|
assert run.ranges == ["broker:1883"]
|
||||||
|
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_run_zwave_sync_records_scan_run(mem_db):
|
||||||
|
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
|
||||||
|
background import with the run id + env payload."""
|
||||||
|
from app.db.models import ScanRun
|
||||||
|
from app.schemas.zwave import ZwaveImportRequest
|
||||||
|
|
||||||
|
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.core.scheduler.settings") as mock_settings,
|
||||||
|
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
|
||||||
|
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
|
||||||
|
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
|
||||||
|
):
|
||||||
|
mock_settings.zwave_sync_enabled = True
|
||||||
|
mock_settings.zwave_mqtt_host = "broker"
|
||||||
|
mock_settings.zwave_mqtt_port = 1883
|
||||||
|
await _run_zwave_sync()
|
||||||
|
|
||||||
|
async with mem_db() as db:
|
||||||
|
from sqlalchemy import select
|
||||||
|
run = (await db.execute(select(ScanRun))).scalars().one()
|
||||||
|
assert run.kind == "zwave"
|
||||||
|
assert run.ranges == ["broker:1883"]
|
||||||
|
mock_bg.assert_awaited_once_with(run.id, fake_payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_zigbee_sync_noop_when_not_running():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||||
|
reschedule_zigbee_sync(600)
|
||||||
|
mock_sched.reschedule_job.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_scheduler_adds_mesh_jobs_when_enabled():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
with patch("app.core.scheduler.settings") as mock_settings, \
|
||||||
|
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
|
||||||
|
mock_settings.status_checker_interval = 60
|
||||||
|
mock_settings.service_check_enabled = False
|
||||||
|
mock_settings.proxmox_sync_enabled = False
|
||||||
|
mock_settings.zigbee_sync_enabled = True
|
||||||
|
mock_settings.zigbee_sync_interval = 3600
|
||||||
|
mock_settings.zwave_sync_enabled = True
|
||||||
|
mock_settings.zwave_sync_interval = 3600
|
||||||
|
start_scheduler()
|
||||||
|
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
|
||||||
|
assert "zigbee_sync" in job_ids
|
||||||
|
assert "zwave_sync" in job_ids
|
||||||
|
|
||||||
|
|
||||||
|
# --- reschedule_* validation and not-running guards ---
|
||||||
|
|
||||||
|
def test_reschedule_status_checks_rejects_short_interval():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reschedule_status_checks(5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_status_checks_noop_when_not_running():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||||
|
reschedule_status_checks(60)
|
||||||
|
mock_sched.reschedule_job.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_status_checks_updates_running_job():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||||
|
reschedule_status_checks(60)
|
||||||
|
mock_sched.reschedule_job.assert_called_once_with(
|
||||||
|
"status_checks", trigger="interval", seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_service_checks_rejects_short_interval():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reschedule_service_checks(10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_service_checks_updates_when_job_exists():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = True
|
||||||
|
mock_sched.get_job.return_value = MagicMock()
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||||
|
reschedule_service_checks(60)
|
||||||
|
mock_sched.reschedule_job.assert_called_once_with(
|
||||||
|
"service_checks", trigger="interval", seconds=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_proxmox_sync_noop_when_not_running():
|
||||||
|
mock_sched = MagicMock()
|
||||||
|
mock_sched.running = False
|
||||||
|
with patch("app.core.scheduler.scheduler", mock_sched):
|
||||||
|
reschedule_proxmox_sync(600)
|
||||||
|
mock_sched.reschedule_job.assert_not_called()
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
"""Tests for GET/POST /api/v1/settings."""
|
"""Tests for GET/POST /api/v1/settings."""
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
@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.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -84,3 +80,90 @@ async def test_update_settings_rejects_too_short_service_interval(client: AsyncC
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
assert res.status_code == 422
|
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
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.services.status_checker import (
|
from app.services.status_checker import (
|
||||||
|
_http_get,
|
||||||
_ping,
|
_ping,
|
||||||
_tcp_connect,
|
_tcp_connect,
|
||||||
check_node,
|
check_node,
|
||||||
@@ -11,6 +12,18 @@ from app.services.status_checker import (
|
|||||||
check_services,
|
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 ---
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -462,3 +475,35 @@ async def test_check_services_returns_status_per_service():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_services_empty_list():
|
async def test_check_services_empty_list():
|
||||||
assert await check_services("10.0.0.1", []) == []
|
assert await check_services("10.0.0.1", []) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_get_true_on_2xx():
|
||||||
|
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
|
||||||
|
assert await _http_get("http://host") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_get_true_on_4xx():
|
||||||
|
# A 4xx means the server is up and answering — still "online".
|
||||||
|
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(404)):
|
||||||
|
assert await _http_get("http://host") is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_get_false_on_5xx():
|
||||||
|
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(503)):
|
||||||
|
assert await _http_get("http://host") is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_check_service_http_exception_returns_offline():
|
||||||
|
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
|
||||||
|
with patch(
|
||||||
|
"app.services.status_checker._http_get",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=RuntimeError("connection refused"),
|
||||||
|
):
|
||||||
|
assert await check_service(svc, "10.0.0.1") == "offline"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
@@ -11,13 +11,6 @@ from httpx import AsyncClient
|
|||||||
# Fixtures
|
# Fixtures
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
async def headers(client: AsyncClient):
|
|
||||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
|
||||||
token = res.json()["access_token"]
|
|
||||||
return {"Authorization": f"Bearer {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /api/v1/zigbee/test-connection
|
# /api/v1/zigbee/test-connection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -338,20 +331,36 @@ async def test_import_pending_endpoint_creates_zigbee_scan_run(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_creates_coordinator_and_pending(
|
async def test_persist_pending_import_coordinator_goes_to_pending(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> 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.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)
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
assert result.device_count == 3
|
assert result.device_count == 3
|
||||||
assert result.pending_created == 2
|
assert result.pending_created == 3 # coordinator included now
|
||||||
assert result.pending_updated == 0
|
assert result.pending_updated == 0
|
||||||
assert result.coordinator is not None
|
assert result.coordinator is None # not auto-placed
|
||||||
assert result.coordinator.ieee_address == "0xCOORD"
|
|
||||||
assert result.coordinator_already_existed is False
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_idempotent_updates_existing(
|
async def test_persist_pending_import_idempotent_updates_existing(
|
||||||
@@ -366,8 +375,8 @@ async def test_persist_pending_import_idempotent_updates_existing(
|
|||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
assert result.pending_created == 0
|
assert result.pending_created == 0
|
||||||
assert result.pending_updated == 2
|
assert result.pending_updated == 3 # coordinator upserts too
|
||||||
assert result.coordinator_already_existed is True
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
assert result.links_recorded == 2
|
||||||
|
|
||||||
|
|
||||||
@@ -389,12 +398,12 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
|
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
|
||||||
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
|
"""Coordinator lands in pending carrying its vendor/model metadata."""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
|
||||||
nodes_with_meta[0]["vendor"] = "TI"
|
nodes_with_meta[0]["vendor"] = "TI"
|
||||||
@@ -403,28 +412,30 @@ async def test_persist_pending_import_sets_coordinator_properties(db_session) ->
|
|||||||
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
await _persist_pending_import(db_session, nodes_with_meta, _PENDING_EDGES)
|
||||||
|
|
||||||
coord = (
|
coord = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
assert coord.vendor == "TI"
|
||||||
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
|
assert coord.model == "CC2652"
|
||||||
# New zigbee props default to hidden — user opts in from the right panel.
|
assert coord.suggested_type == "zigbee_coordinator"
|
||||||
assert all(p["visible"] is False for p in coord.properties)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_skips_pending_for_approved_node(
|
async def test_persist_pending_import_backfills_inventory_for_approved_node(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""A device already approved as a canvas Node must not reappear in pending.
|
"""A device on canvas but missing its inventory row gets one backfilled
|
||||||
|
(status="approved"), so it shows in the inventory list. Node props still
|
||||||
Its properties must still be refreshed with the latest Vendor/Model/LQI.
|
refresh with the latest Vendor/Model/LQI.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node, PendingDevice
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
# Simulate: router was approved earlier → exists as a canvas Node.
|
# 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(
|
approved = Node(
|
||||||
label="router_1",
|
label="router_1",
|
||||||
type="zigbee_router",
|
type="zigbee_router",
|
||||||
@@ -441,13 +452,15 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
bumped[1]["lqi"] = 250 # new LQI from re-import
|
bumped[1]["lqi"] = 250 # new LQI from re-import
|
||||||
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
|
|
||||||
# No PendingDevice row was created for the approved router.
|
# An inventory row is backfilled as "approved" (it is on a canvas).
|
||||||
pendings = (
|
inv = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalar_one()
|
||||||
assert pendings == []
|
assert inv.status == "approved"
|
||||||
|
assert inv.suggested_type == "zigbee_router"
|
||||||
|
assert inv.device_subtype == "Router"
|
||||||
|
|
||||||
# Node properties got refreshed.
|
# Node properties got refreshed.
|
||||||
refreshed = (
|
refreshed = (
|
||||||
@@ -459,6 +472,37 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
|
|||||||
assert all(p["visible"] is False for p in refreshed.properties)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_revives_orphaned_approved_device(
|
async def test_persist_pending_import_revives_orphaned_approved_device(
|
||||||
db_session,
|
db_session,
|
||||||
@@ -501,8 +545,8 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert revived.status == "pending"
|
assert revived.status == "pending"
|
||||||
# End device 0xE1 is brand new → created as pending; router was updated.
|
# Coordinator + end device 0xE1 are brand new → created; router was revived.
|
||||||
assert result.pending_created == 1
|
assert result.pending_created == 2
|
||||||
assert result.pending_updated == 1
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
# It is now visible to the Pending list (status filter == "pending").
|
# It is now visible to the Pending list (status filter == "pending").
|
||||||
@@ -511,7 +555,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
|
|||||||
select(PendingDevice).where(PendingDevice.status == "pending")
|
select(PendingDevice).where(PendingDevice.status == "pending")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
assert {p.ieee_address for p in listed} == {"0xR1", "0xE1"}
|
assert {p.ieee_address for p in listed} == {"0xCOORD", "0xR1", "0xE1"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -591,15 +635,23 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
async def test_persist_pending_import_refreshes_approved_coordinator_node(
|
||||||
db_session,
|
db_session,
|
||||||
) -> None:
|
) -> 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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zigbee import _persist_pending_import
|
from app.api.routes.zigbee import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import Node, PendingDevice
|
||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
# 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 = [dict(n) for n in _PENDING_NODES]
|
||||||
bumped[0]["vendor"] = "TI"
|
bumped[0]["vendor"] = "TI"
|
||||||
@@ -612,10 +664,53 @@ async def test_persist_pending_import_refreshes_existing_coordinator_properties(
|
|||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
keys = {p["key"]: p["value"] for p in coord.properties}
|
||||||
assert keys["Vendor"] == "TI"
|
assert keys["Vendor"] == "TI"
|
||||||
assert keys["Model"] == "CC2652"
|
assert keys["Model"] == "CC2652"
|
||||||
# Newly added keys on re-import default to hidden.
|
|
||||||
by_key = {p["key"]: p for p in coord.properties}
|
by_key = {p["key"]: p for p in coord.properties}
|
||||||
assert by_key["Vendor"]["visible"] is False
|
assert by_key["Vendor"]["visible"] is False
|
||||||
assert by_key["Model"]["visible"] is False
|
# Inventory row backfilled as approved → now visible in the inventory list.
|
||||||
|
inv = (
|
||||||
|
await db_session.execute(
|
||||||
|
select(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
|
@pytest.mark.asyncio
|
||||||
@@ -645,3 +740,106 @@ async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> N
|
|||||||
kwargs = mock_conn.call_args.kwargs
|
kwargs = mock_conn.call_args.kwargs
|
||||||
assert kwargs["tls"] is True
|
assert kwargs["tls"] is True
|
||||||
assert kwargs["tls_insecure"] 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
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ from unittest.mock import patch
|
|||||||
import pytest
|
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}"}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# /api/v1/zwave/test-connection
|
# /api/v1/zwave/test-connection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -297,18 +289,31 @@ async def test_import_pending_requires_auth(client: AsyncClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_creates_coordinator_and_pending(db_session) -> None:
|
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.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)
|
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
assert result.device_count == 3
|
assert result.device_count == 3
|
||||||
assert result.pending_created == 2
|
assert result.pending_created == 3 # coordinator included now
|
||||||
assert result.pending_updated == 0
|
assert result.pending_updated == 0
|
||||||
assert result.coordinator is not None
|
assert result.coordinator is None # not auto-placed
|
||||||
assert result.coordinator.ieee_address == "zwave-0xh-1"
|
|
||||||
assert result.coordinator_already_existed is False
|
assert result.coordinator_already_existed is False
|
||||||
assert result.links_recorded == 2
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
async def test_persist_idempotent_updates_existing(db_session) -> None:
|
||||||
@@ -319,8 +324,8 @@ async def test_persist_idempotent_updates_existing(db_session) -> None:
|
|||||||
bumped[1]["model"] = "ZW111"
|
bumped[1]["model"] = "ZW111"
|
||||||
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
|
||||||
assert result.pending_created == 0
|
assert result.pending_created == 0
|
||||||
assert result.pending_updated == 2
|
assert result.pending_updated == 3 # coordinator upserts too
|
||||||
assert result.coordinator_already_existed is True
|
assert result.coordinator_already_existed is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -339,26 +344,31 @@ async def test_persist_replaces_links(db_session) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_sets_coordinator_properties(db_session) -> None:
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
from app.db.models import Node
|
from app.db.models import PendingDevice
|
||||||
|
|
||||||
nodes = [dict(n) for n in _PENDING_NODES]
|
nodes = [dict(n) for n in _PENDING_NODES]
|
||||||
nodes[0]["vendor"] = "Aeotec"
|
nodes[0]["vendor"] = "Aeotec"
|
||||||
nodes[0]["model"] = "ZW090"
|
nodes[0]["model"] = "ZW090"
|
||||||
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
|
||||||
coord = (
|
coord = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-1"))
|
await db_session.execute(
|
||||||
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
|
||||||
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
keys = {p["key"]: p["value"] for p in coord.properties}
|
assert coord.vendor == "Aeotec"
|
||||||
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
|
assert coord.model == "ZW090"
|
||||||
assert all(p["visible"] is False for p in coord.properties)
|
assert coord.suggested_type == "zwave_coordinator"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
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 sqlalchemy import select
|
||||||
|
|
||||||
from app.api.routes.zwave import _persist_pending_import
|
from app.api.routes.zwave import _persist_pending_import
|
||||||
@@ -378,12 +388,13 @@ async def test_persist_skips_pending_for_approved_node(db_session) -> None:
|
|||||||
|
|
||||||
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
|
||||||
|
|
||||||
pendings = (
|
inv = (
|
||||||
await db_session.execute(
|
await db_session.execute(
|
||||||
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalar_one()
|
||||||
assert pendings == []
|
assert inv.status == "approved"
|
||||||
|
assert inv.suggested_type == "zwave_router"
|
||||||
refreshed = (
|
refreshed = (
|
||||||
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
@@ -416,7 +427,8 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert revived.status == "pending"
|
assert revived.status == "pending"
|
||||||
assert result.pending_created == 1
|
# Coordinator + end device are brand new → created; router was revived.
|
||||||
|
assert result.pending_created == 2
|
||||||
assert result.pending_updated == 1
|
assert result.pending_updated == 1
|
||||||
|
|
||||||
|
|
||||||
@@ -445,3 +457,143 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
|
|||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
assert still_hidden.status == "hidden"
|
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
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Proxmox VE Import
|
||||||
|
|
||||||
|
This feature connects Homelable to your Proxmox VE host, reads the inventory over
|
||||||
|
the Proxmox REST API, and drops your hosts, VMs and LXC containers onto the canvas
|
||||||
|
as typed nodes — with names, run state and hardware specs. It can also **sync**
|
||||||
|
on a schedule so the canvas keeps up with your cluster, and it **merges** with
|
||||||
|
devices already discovered by a network scan (a VM whose guest IP was already
|
||||||
|
scanned is updated in place, not duplicated).
|
||||||
|
|
||||||
|
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
|
||||||
|
> in the no-backend standalone/demo build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Overview
|
||||||
|
|
||||||
|
- **API-based discovery** — Reads `/api2/json` from Proxmox VE using a read-only API token.
|
||||||
|
- **Typed nodes** — Devices map to existing Homelable node types:
|
||||||
|
- `proxmox` — a Proxmox host / cluster member (becomes a parent)
|
||||||
|
- `vm` — a QEMU/KVM virtual machine
|
||||||
|
- `lxc` — an LXC container
|
||||||
|
- **Hierarchy** — Each host is linked to its VMs/LXC with a `virtual` edge.
|
||||||
|
- **Hardware specs** — vCPU count, RAM and disk size are imported as node properties (CPU Cores, RAM, Disk), hidden by default — toggle them on from the right panel.
|
||||||
|
- **Guest IPs** — QEMU IPs come from the guest agent (when installed); LXC IPs are parsed from the container's static `net0` config.
|
||||||
|
- **Merge / sync** — Re-importing updates existing devices in place and never deletes anything. A guest IP matching a previously scanned node merges onto it.
|
||||||
|
- **Auto-sync** — Optional scheduled re-import into the pending inventory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. A reachable **Proxmox VE** host (default API port `8006`).
|
||||||
|
2. A **Proxmox API token** with a read-only role (see below).
|
||||||
|
3. (Optional, for QEMU guest IPs) the **QEMU guest agent** installed in your VMs.
|
||||||
|
|
||||||
|
### Create an API token
|
||||||
|
|
||||||
|
In the Proxmox web UI:
|
||||||
|
|
||||||
|
1. **Datacenter → Permissions → API Tokens → Add**.
|
||||||
|
2. Pick a **User** (e.g. `root@pam`, or better a dedicated `homelable@pve` user).
|
||||||
|
3. Give the token an **ID** (e.g. `homelable`). The full token id is then
|
||||||
|
`user@realm!tokenid` — for example `root@pam!homelable`.
|
||||||
|
4. Leave **Privilege Separation** checked (recommended) and click **Add**.
|
||||||
|
5. **Copy the secret now** — Proxmox shows it only once.
|
||||||
|
|
||||||
|
Grant the token (or its user) a **read-only** role:
|
||||||
|
|
||||||
|
1. **Datacenter → Permissions → Add → API Token Permission**.
|
||||||
|
2. Path `/`, select your token, Role **`PVEAuditor`**, enable **Propagate**.
|
||||||
|
|
||||||
|
`PVEAuditor` is read-only — Homelable never needs write access.
|
||||||
|
|
||||||
|
### Where the token is stored
|
||||||
|
|
||||||
|
The token is a real credential and is treated as one:
|
||||||
|
|
||||||
|
- For a **one-off import**, type the token into the import dialog. It is sent with
|
||||||
|
that request only and is **never stored**.
|
||||||
|
- For **auto-sync** (which runs with no user present), configure the token on the
|
||||||
|
**server** via environment variables (below). It is read from `.env`, kept in
|
||||||
|
memory.
|
||||||
|
|
||||||
|
```env
|
||||||
|
# backend/.env
|
||||||
|
PROXMOX_TOKEN_ID=root@pam!homelable
|
||||||
|
PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
|
PROXMOX_HOST=192.168.1.10 # optional default for auto-sync
|
||||||
|
PROXMOX_PORT=8006
|
||||||
|
PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step-by-step Usage
|
||||||
|
|
||||||
|
### 1. Open the Proxmox Import dialog
|
||||||
|
|
||||||
|
Click **Proxmox Import** in the left sidebar (below "Z-Wave Import").
|
||||||
|
|
||||||
|
### 2. Configure the connection
|
||||||
|
|
||||||
|
| Field | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Proxmox Host | — | IP or hostname of the Proxmox host |
|
||||||
|
| Port | 8006 | Proxmox API port |
|
||||||
|
| Token ID | _(optional)_ | `user@realm!tokenid`; leave blank to use the server token |
|
||||||
|
| Token Secret | _(optional)_ | The token secret; leave blank to use the server token |
|
||||||
|
| Verify TLS | on | Uncheck for self-signed certificates |
|
||||||
|
|
||||||
|
### 3. Test the connection (optional)
|
||||||
|
|
||||||
|
Click **Test Connection**. A green indicator confirms reachability + a valid token;
|
||||||
|
red shows a sanitized error.
|
||||||
|
|
||||||
|
### 4. Choose an import target
|
||||||
|
|
||||||
|
- **Pending section** — Devices are queued in the Device Inventory for review
|
||||||
|
(and tracked as a scan run in Scan History). Approve, hide, or delete each.
|
||||||
|
- **Canvas directly** — Devices are fetched and shown grouped in the dialog so you
|
||||||
|
can pick which ones to add immediately.
|
||||||
|
|
||||||
|
### 5. Fetch inventory
|
||||||
|
|
||||||
|
Click **Import to Pending** (or **Fetch Inventory** in canvas mode). Homelable will:
|
||||||
|
1. Query `/nodes` for hosts
|
||||||
|
2. Query `/qemu` and `/lxc` per host
|
||||||
|
3. Resolve guest IPs (agent for QEMU, `net0` for LXC) best-effort
|
||||||
|
4. Return hosts + guests grouped by type
|
||||||
|
|
||||||
|
### 6. Select and add to canvas
|
||||||
|
|
||||||
|
(Canvas mode) Devices are grouped by type (Hosts / Virtual Machines / LXC
|
||||||
|
Containers). Use the checkboxes to pick which to add, then **Add N to Canvas**.
|
||||||
|
|
||||||
|
### 7. Arrange on the canvas
|
||||||
|
|
||||||
|
Nodes are placed in a grid; host→guest `virtual` edges connect them. Use
|
||||||
|
**Auto Layout** or drag nodes manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node Type Mapping
|
||||||
|
|
||||||
|
| Proxmox (`/api2/json`) | Homelable type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `/nodes` (host) | `proxmox` | Becomes the parent, linked to its guests |
|
||||||
|
| `/nodes/{node}/qemu/{vmid}` | `vm` | Guest-agent IP when available |
|
||||||
|
| `/nodes/{node}/lxc/{vmid}` | `lxc` | Static `net0` IP when set |
|
||||||
|
| `status` running/stopped | node status online/offline | |
|
||||||
|
| `maxcpu` | CPU Cores property | hidden by default |
|
||||||
|
| `maxmem` | RAM property (GB) | hidden by default |
|
||||||
|
| `maxdisk` | Disk property (GB) | hidden by default |
|
||||||
|
| VMID + host | synthetic identity (`pve-{host}-{vmid}`) | stable across re-imports |
|
||||||
|
|
||||||
|
Guest hierarchy is rendered as `virtual` edges (host ↔ VM/LXC).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-sync configuration
|
||||||
|
|
||||||
|
1. Configure `PROXMOX_TOKEN_ID` / `PROXMOX_TOKEN_SECRET` (and optionally
|
||||||
|
`PROXMOX_HOST`) in `backend/.env` and restart the backend.
|
||||||
|
2. Open **Settings** — a **Proxmox auto-sync** section appears once a server token
|
||||||
|
is configured.
|
||||||
|
3. Toggle **Auto-sync Proxmox inventory** and set the interval (min 300 s).
|
||||||
|
|
||||||
|
On each run, Homelable re-imports the inventory into the pending section:
|
||||||
|
- New VMs/LXC appear as **pending** for review.
|
||||||
|
- Existing devices are **updated in place** (status, specs, IP).
|
||||||
|
- Nothing is ever deleted — a VM removed from Proxmox is left on your canvas.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Versions
|
||||||
|
|
||||||
|
Works with the Proxmox VE 7.x / 8.x REST API (`/api2/json`). No extra Proxmox
|
||||||
|
plugins are required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| "Authentication failed" | Bad token id/secret or missing role | Re-check the token; grant `PVEAuditor` at `/` |
|
||||||
|
| "TLS verification failed" | Self-signed certificate | Uncheck **Verify TLS** (labs only) |
|
||||||
|
| "Proxmox host could not be resolved" | DNS/hostname wrong | Use the IP or a resolvable name |
|
||||||
|
| "No API token provided and none configured" | No token in the form and none in `.env` | Enter a token or set the server env vars |
|
||||||
|
| VMs have no IP | No guest agent (QEMU) / DHCP-only LXC | Install the QEMU guest agent; static IPs are read from `net0` |
|
||||||
|
| Duplicate-looking node | Same guest under a different identity | Re-import merges by IP/VMID; report if it persists |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.6.1",
|
"version": "3.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.6.1",
|
"version": "3.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.6.1",
|
"version": "3.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+182
-28
@@ -2,12 +2,13 @@ import { useEffect, useCallback, useRef, useState } from 'react'
|
|||||||
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
|
||||||
import { type Node } from '@xyflow/react'
|
import { type Node } from '@xyflow/react'
|
||||||
import { applyDagreLayout } from '@/utils/layout'
|
import { applyDagreLayout } from '@/utils/layout'
|
||||||
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { serializeNode, serializeEdge, deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { generateUUID } from '@/utils/uuid'
|
import { generateUUID } from '@/utils/uuid'
|
||||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||||
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
|
||||||
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
import { generateMarkdownTable } from '@/utils/exportMarkdown'
|
||||||
import { copyToClipboard } from '@/utils/clipboard'
|
import { copyToClipboard } from '@/utils/clipboard'
|
||||||
|
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
|
||||||
import { ExportModal } from '@/components/modals/ExportModal'
|
import { ExportModal } from '@/components/modals/ExportModal'
|
||||||
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
|
||||||
import { parseYamlToCanvas } from '@/utils/importYaml'
|
import { parseYamlToCanvas } from '@/utils/importYaml'
|
||||||
@@ -25,15 +26,19 @@ import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
|
|||||||
import { SettingsModal } from '@/components/modals/SettingsModal'
|
import { SettingsModal } from '@/components/modals/SettingsModal'
|
||||||
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
|
||||||
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
|
||||||
|
import { ProxmoxImportModal } from '@/components/proxmox/ProxmoxImportModal'
|
||||||
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
|
||||||
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
|
||||||
import { ThemeModal } from '@/components/modals/ThemeModal'
|
import { ThemeModal } from '@/components/modals/ThemeModal'
|
||||||
|
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
|
||||||
import { SearchModal } from '@/components/modals/SearchModal'
|
import { SearchModal } from '@/components/modals/SearchModal'
|
||||||
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
|
||||||
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings'
|
||||||
|
import { useAutosave } from '@/hooks/useAutosave'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
@@ -41,14 +46,16 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
|||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
|
||||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||||
|
import type { ProxmoxNode, ProxmoxEdge } from '@/components/proxmox/types'
|
||||||
|
import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore()
|
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
|
||||||
const canvasRef = useRef<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -56,7 +63,19 @@ export default function App() {
|
|||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
|
// Provenance: which design the in-memory canvas was loaded as. Differs from
|
||||||
|
// activeDesignId (the selection) during a switch, so autosave gates on this to
|
||||||
|
// avoid writing one design's canvas under another's id. Ref mirror for the
|
||||||
|
// fire-time guard, which must read the live value without re-arming the timer.
|
||||||
|
const [loadedDesignId, setLoadedDesignId] = useState<string | null>(null)
|
||||||
|
const loadedDesignIdRef = useRef<string | null>(loadedDesignId)
|
||||||
|
useEffect(() => { loadedDesignIdRef.current = loadedDesignId }, [loadedDesignId])
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
|
||||||
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
const [pendingModalOpen, setPendingModalOpen] = useState(false)
|
||||||
@@ -82,36 +101,55 @@ export default function App() {
|
|||||||
const [exportModalOpen, setExportModalOpen] = useState(false)
|
const [exportModalOpen, setExportModalOpen] = useState(false)
|
||||||
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
|
||||||
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
|
||||||
|
const [proxmoxImportOpen, setProxmoxImportOpen] = useState(false)
|
||||||
|
|
||||||
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
// Returns true on success, false on failure — the design-switch effect relies
|
// Returns true on success, false on failure — the design-switch effect relies
|
||||||
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const saveDesignId = designIdOverride ?? activeDesignId
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
if (!saveDesignId) return false
|
if (!saveDesignId) return false
|
||||||
|
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
||||||
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
const edgesToSave = edges.map(serializeEdge)
|
const edgesToSave = edges.map(serializeEdge)
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId })
|
const viewport: Record<string, unknown> = { theme_id: activeTheme }
|
||||||
|
if (floorMap) viewport.floor_map = floorMap
|
||||||
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId])
|
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap])
|
||||||
|
|
||||||
// Keep a ref so the keydown handler always calls the latest version
|
// Keep a ref so the keydown handler always calls the latest version
|
||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
useEffect(() => { handleSaveRef.current = handleSave }, [handleSave])
|
||||||
|
|
||||||
|
// Debounced, opt-in autosave. Pins the active design when armed and re-checks
|
||||||
|
// it at fire time so a mid-switch save can't clobber the wrong design.
|
||||||
|
useAutosave({
|
||||||
|
enabled: autosave.enabled,
|
||||||
|
delaySeconds: autosave.delay,
|
||||||
|
hasUnsavedChanges,
|
||||||
|
designId: loadedDesignId,
|
||||||
|
// Debounce resets on each real user edit (editSeq), not on raw nodes/edges
|
||||||
|
// identity — live status polling churns those arrays without a user edit and
|
||||||
|
// would otherwise keep re-arming (and starving) the timer during monitoring.
|
||||||
|
changeSignals: [editSeq],
|
||||||
|
getLiveDesignId: () => loadedDesignIdRef.current,
|
||||||
|
onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) },
|
||||||
|
})
|
||||||
|
|
||||||
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await canvasApi.load(designId)
|
const res = await canvasApi.load(designId)
|
||||||
@@ -122,19 +160,30 @@ export default function App() {
|
|||||||
.filter((n) => n.type === 'group' || n.container_mode === true)
|
.filter((n) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n) => [n.id, true])
|
.map((n) => [n.id, true])
|
||||||
)
|
)
|
||||||
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
|
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
|
||||||
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
|
||||||
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
|
)
|
||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
|
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
|
||||||
|
// Clear when the target design has no floor plan, so it doesn't bleed
|
||||||
|
// across canvases when switching designs.
|
||||||
|
setFloorMap(savedFloorMap ?? null)
|
||||||
loadCanvas(rfNodes, rfEdges)
|
loadCanvas(rfNodes, rfEdges)
|
||||||
} else {
|
} else {
|
||||||
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
|
} finally {
|
||||||
|
// Record provenance so autosave writes back under the design just loaded.
|
||||||
|
setLoadedDesignId(designId ?? null)
|
||||||
}
|
}
|
||||||
}, [loadCanvas, setTheme, setCustomStyle])
|
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||||
|
|
||||||
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
||||||
// localStorage, falling back to the demo canvas when it has never been saved.
|
// localStorage, falling back to the demo canvas when it has never been saved.
|
||||||
@@ -143,17 +192,28 @@ export default function App() {
|
|||||||
if (saved && saved.nodes.length > 0) {
|
if (saved && saved.nodes.length > 0) {
|
||||||
if (saved.theme_id) setTheme(saved.theme_id)
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
loadCanvas(saved.nodes, saved.edges)
|
// Floor plans are backend-only; keep the store clear in standalone mode.
|
||||||
|
setFloorMap(null)
|
||||||
|
const migrated = migrateClusterHandles(saved.nodes, saved.edges)
|
||||||
|
loadCanvas(migrated.nodes, migrated.edges)
|
||||||
} else {
|
} else {
|
||||||
|
setFloorMap(null)
|
||||||
loadCanvas(demoNodes, demoEdges)
|
loadCanvas(demoNodes, demoEdges)
|
||||||
}
|
}
|
||||||
}, [loadCanvas, setTheme, setCustomStyle])
|
// Record provenance so autosave writes back under the design just loaded.
|
||||||
|
setLoadedDesignId(designId)
|
||||||
|
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||||
|
|
||||||
const loadDesignsAndCanvas = useCallback(async () => {
|
const loadDesignsAndCanvas = useCallback(async () => {
|
||||||
|
// Prefer a design id explicitly requested via the URL (?design=<id>), so a
|
||||||
|
// refresh or shared link opens that design. Ignore it when it doesn't match
|
||||||
|
// a known design and fall back to the current/default one.
|
||||||
|
const urlDesignId = getDesignIdFromUrl()
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
const designs = standaloneStorage.ensureSeed()
|
const designs = standaloneStorage.ensureSeed()
|
||||||
setDesigns(designs)
|
setDesigns(designs)
|
||||||
const targetId = activeDesignId ?? designs[0]?.id
|
const fromUrl = urlDesignId && designs.some((d) => d.id === urlDesignId) ? urlDesignId : null
|
||||||
|
const targetId = fromUrl ?? activeDesignId ?? designs[0]?.id
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
setActiveDesign(targetId)
|
setActiveDesign(targetId)
|
||||||
loadStandaloneCanvas(targetId)
|
loadStandaloneCanvas(targetId)
|
||||||
@@ -164,7 +224,8 @@ export default function App() {
|
|||||||
const res = await designsApi.list()
|
const res = await designsApi.list()
|
||||||
const loadedDesigns = res.data
|
const loadedDesigns = res.data
|
||||||
setDesigns(loadedDesigns)
|
setDesigns(loadedDesigns)
|
||||||
const targetId = activeDesignId ?? loadedDesigns[0]?.id
|
const fromUrl = urlDesignId && loadedDesigns.some((d) => d.id === urlDesignId) ? urlDesignId : null
|
||||||
|
const targetId = fromUrl ?? activeDesignId ?? loadedDesigns[0]?.id
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
setActiveDesign(targetId)
|
setActiveDesign(targetId)
|
||||||
await loadCanvasFromApi(targetId)
|
await loadCanvasFromApi(targetId)
|
||||||
@@ -239,6 +300,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [activeDesignId])
|
}, [activeDesignId])
|
||||||
|
|
||||||
|
// Reflect the active design into the URL so refresh/share reopens it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeDesignId) setDesignIdInUrl(activeDesignId)
|
||||||
|
}, [activeDesignId])
|
||||||
|
|
||||||
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
// Keep refs for store actions so keydown handler is always up-to-date without re-registering
|
||||||
const undoRef = useRef(undo)
|
const undoRef = useRef(undo)
|
||||||
const redoRef = useRef(redo)
|
const redoRef = useRef(redo)
|
||||||
@@ -426,8 +492,13 @@ export default function App() {
|
|||||||
snapshotHistory()
|
snapshotHistory()
|
||||||
const existingNode = nodes.find((n) => n.id === editNodeId)
|
const existingNode = nodes.find((n) => n.id === editNodeId)
|
||||||
updateNode(editNodeId, data)
|
updateNode(editNodeId, data)
|
||||||
// If container_mode changed, apply structural changes (children parentId, node dimensions)
|
// Only run the structural container transition when container_mode ACTUALLY
|
||||||
if (typeof data.container_mode === 'boolean') {
|
// changed. The modal always includes container_mode in its payload, so firing
|
||||||
|
// on presence alone re-ran the absolute<->relative child-position conversion on
|
||||||
|
// every edit (e.g. an icon change), corrupting nested child positions (they pile
|
||||||
|
// up in a corner). Gate on a real toggle instead.
|
||||||
|
const prevContainerMode = !!existingNode?.data.container_mode
|
||||||
|
if (typeof data.container_mode === 'boolean' && data.container_mode !== prevContainerMode) {
|
||||||
setProxmoxContainerMode(editNodeId, data.container_mode)
|
setProxmoxContainerMode(editNodeId, data.container_mode)
|
||||||
}
|
}
|
||||||
// Sync virtual edge when parent_id changes on an LXC/VM node
|
// Sync virtual edge when parent_id changes on an LXC/VM node
|
||||||
@@ -460,9 +531,11 @@ export default function App() {
|
|||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
const handleAutoLayout = useCallback(() => {
|
||||||
const laid = applyDagreLayout(nodes, edges)
|
const laid = applyDagreLayout(nodes, edges)
|
||||||
loadCanvas(laid, edges)
|
// applyLayout keeps undo history so the user can revert an accidental
|
||||||
|
// Auto Layout (#280); loadCanvas would wipe it.
|
||||||
|
applyLayout(laid, edges)
|
||||||
toast.success('Canvas auto-arranged')
|
toast.success('Canvas auto-arranged')
|
||||||
}, [nodes, edges, loadCanvas])
|
}, [nodes, edges, applyLayout])
|
||||||
|
|
||||||
const handleExportMd = useCallback(async () => {
|
const handleExportMd = useCallback(async () => {
|
||||||
const md = generateMarkdownTable(nodes)
|
const md = generateMarkdownTable(nodes)
|
||||||
@@ -484,14 +557,14 @@ export default function App() {
|
|||||||
const handleImportYaml = useCallback((content: string) => {
|
const handleImportYaml = useCallback((content: string) => {
|
||||||
try {
|
try {
|
||||||
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
const { nodes: merged, edges: mergedEdges, imported } = parseYamlToCanvas(content, nodes, edges)
|
||||||
snapshotHistory()
|
// applyLayout keeps undo history so an import can be reverted; loadCanvas
|
||||||
loadCanvas(merged, mergedEdges)
|
// would wipe it (#280).
|
||||||
markUnsaved()
|
applyLayout(merged, mergedEdges)
|
||||||
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
toast.success(`Imported ${imported} node${imported !== 1 ? 's' : ''}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
|
toast.error(`Import failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
}
|
}
|
||||||
}, [nodes, edges, snapshotHistory, loadCanvas, markUnsaved])
|
}, [nodes, edges, applyLayout])
|
||||||
|
|
||||||
// Open the read-only live view of the currently active design in a new tab.
|
// Open the read-only live view of the currently active design in a new tab.
|
||||||
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
// Standalone has no backend/key — it reads localStorage, so just open /view.
|
||||||
@@ -622,6 +695,69 @@ export default function App() {
|
|||||||
markUnsaved()
|
markUnsaved()
|
||||||
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
|
const handleProxmoxAddToCanvas = useCallback((pmNodes: ProxmoxNode[], pmEdges: ProxmoxEdge[]) => {
|
||||||
|
snapshotHistory()
|
||||||
|
const COLS = 4
|
||||||
|
const SPACING_X = 190
|
||||||
|
const SPACING_Y = 110
|
||||||
|
const cols = Math.min(COLS, pmNodes.length)
|
||||||
|
const rows = Math.ceil(pmNodes.length / COLS)
|
||||||
|
const origin = getCenteredPosition(cols * SPACING_X, rows * SPACING_Y)
|
||||||
|
// Multiple hosts from one import = a cluster → chain them via left/right
|
||||||
|
// 'cluster' edges. Those endpoints need one left + one right handle each
|
||||||
|
// (both default to 0), so grant them to the host nodes up front.
|
||||||
|
const clusterEdges = buildProxmoxClusterEdges(pmNodes)
|
||||||
|
const cluster = clusterEdges.length > 0
|
||||||
|
pmNodes.forEach((pn, i) => {
|
||||||
|
const col = i % COLS
|
||||||
|
const row = Math.floor(i / COLS)
|
||||||
|
const position = { x: origin.x + col * SPACING_X, y: origin.y + row * SPACING_Y }
|
||||||
|
const isClusterHost = cluster && pn.type === 'proxmox'
|
||||||
|
const newNode: import('@xyflow/react').Node<NodeData> = {
|
||||||
|
id: pn.id,
|
||||||
|
type: pn.type,
|
||||||
|
position,
|
||||||
|
data: {
|
||||||
|
label: pn.label,
|
||||||
|
type: pn.type as NodeData['type'],
|
||||||
|
status: (pn.status === 'online' ? 'online' : 'unknown') as NodeData['status'],
|
||||||
|
services: [],
|
||||||
|
...(pn.ip ? { ip: pn.ip } : {}),
|
||||||
|
...(pn.hostname ? { hostname: pn.hostname } : {}),
|
||||||
|
...(isClusterHost ? { left_handles: 1, right_handles: 1 } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addNode(newNode)
|
||||||
|
})
|
||||||
|
// Host → guest links render as 'virtual' edges (VM/LXC ↔ host).
|
||||||
|
pmEdges.forEach((pe) => {
|
||||||
|
onConnect({
|
||||||
|
source: pe.source,
|
||||||
|
sourceHandle: 'bottom',
|
||||||
|
target: pe.target,
|
||||||
|
targetHandle: 'top-t',
|
||||||
|
type: 'virtual',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
// Host ↔ host links render as 'cluster' edges (left → right chain).
|
||||||
|
clusterEdges.forEach((ce) => {
|
||||||
|
onConnect({
|
||||||
|
source: ce.source,
|
||||||
|
sourceHandle: ce.sourceHandle,
|
||||||
|
target: ce.target,
|
||||||
|
targetHandle: ce.targetHandle,
|
||||||
|
type: 'cluster',
|
||||||
|
} as unknown as import('@xyflow/react').Connection)
|
||||||
|
})
|
||||||
|
const importedIds = new Set(pmNodes.map((pn) => pn.id))
|
||||||
|
useCanvasStore.setState((state) => ({
|
||||||
|
nodes: state.nodes.map((n) => ({ ...n, selected: importedIds.has(n.id) })),
|
||||||
|
selectedNodeIds: Array.from(importedIds),
|
||||||
|
selectedNodeId: importedIds.size === 1 ? Array.from(importedIds)[0] : null,
|
||||||
|
}))
|
||||||
|
markUnsaved()
|
||||||
|
}, [addNode, onConnect, snapshotHistory, markUnsaved])
|
||||||
|
|
||||||
const handleEdgeConnect = useCallback((connection: Connection) => {
|
const handleEdgeConnect = useCallback((connection: Connection) => {
|
||||||
setPendingConnection(connection)
|
setPendingConnection(connection)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -697,6 +833,7 @@ export default function App() {
|
|||||||
onScan={() => setScanConfigOpen(true)}
|
onScan={() => setScanConfigOpen(true)}
|
||||||
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
onZigbeeImport={() => setZigbeeImportOpen(true)}
|
||||||
onZwaveImport={() => setZwaveImportOpen(true)}
|
onZwaveImport={() => setZwaveImportOpen(true)}
|
||||||
|
onProxmoxImport={() => setProxmoxImportOpen(true)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onOpenSettings={() => setSettingsOpen(true)}
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
onOpenHistory={() => setScanHistoryOpen(true)}
|
onOpenHistory={() => setScanHistoryOpen(true)}
|
||||||
@@ -740,6 +877,7 @@ export default function App() {
|
|||||||
onSubmit={handleAddNode}
|
onSubmit={handleAddNode}
|
||||||
title="Add Node"
|
title="Add Node"
|
||||||
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
parentCandidates={nodes.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))}
|
||||||
|
onEditTypeStyle={setStyleEditorType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* key forces re-mount when editing a different node, resetting form state */}
|
{/* key forces re-mount when editing a different node, resetting form state */}
|
||||||
@@ -769,6 +907,7 @@ export default function App() {
|
|||||||
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
.map((n) => ({ id: n.id, label: n.data.label ?? n.id, type: n.data.type, container_mode: n.data.container_mode }))
|
||||||
})()}
|
})()}
|
||||||
currentNodeId={editNodeId ?? undefined}
|
currentNodeId={editNodeId ?? undefined}
|
||||||
|
onEditTypeStyle={setStyleEditorType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -776,11 +915,6 @@ export default function App() {
|
|||||||
open={!!pendingConnection}
|
open={!!pendingConnection}
|
||||||
onClose={() => setPendingConnection(null)}
|
onClose={() => setPendingConnection(null)}
|
||||||
onSubmit={handleEdgeConfirm}
|
onSubmit={handleEdgeConfirm}
|
||||||
initial={
|
|
||||||
pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster')
|
|
||||||
? { type: 'cluster' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<EdgeModal
|
<EdgeModal
|
||||||
@@ -826,6 +960,17 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!STANDALONE && (
|
||||||
|
<ProxmoxImportModal
|
||||||
|
open={proxmoxImportOpen}
|
||||||
|
onClose={() => setProxmoxImportOpen(false)}
|
||||||
|
onAddToCanvas={handleProxmoxAddToCanvas}
|
||||||
|
onPendingImported={() => {
|
||||||
|
toast.success('Proxmox import started — check Scan History for results')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<ScanHistoryModal
|
<ScanHistoryModal
|
||||||
open={scanHistoryOpen}
|
open={scanHistoryOpen}
|
||||||
@@ -906,6 +1051,15 @@ export default function App() {
|
|||||||
onClose={() => setThemeModalOpen(false)}
|
onClose={() => setThemeModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Standalone Custom Style editor, opened from a node's Appearance
|
||||||
|
shortcut with that node's type preselected. */}
|
||||||
|
<CustomStyleModal
|
||||||
|
key={styleEditorType ? `style-${styleEditorType}` : 'style-closed'}
|
||||||
|
open={styleEditorType !== null}
|
||||||
|
initialNodeType={styleEditorType ?? undefined}
|
||||||
|
onClose={() => setStyleEditorType(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SearchModal
|
<SearchModal
|
||||||
open={searchOpen}
|
open={searchOpen}
|
||||||
onClose={() => setSearchOpen(false)}
|
onClose={() => setSearchOpen(false)}
|
||||||
|
|||||||
@@ -229,4 +229,22 @@ describe('api/client', () => {
|
|||||||
mod.zwaveApi.importToPending(cfg)
|
mod.zwaveApi.importToPending(cfg)
|
||||||
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
expect(api.post).toHaveBeenCalledWith('/zwave/import-pending', cfg)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('proxmoxApi.testConnection/importNetwork/importToPending', () => {
|
||||||
|
const cfg = { host: 'pve', port: 8006, token_id: 'u@pam!t', token_secret: 's', verify_tls: true }
|
||||||
|
mod.proxmoxApi.testConnection(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/proxmox/test-connection', cfg)
|
||||||
|
mod.proxmoxApi.importNetwork(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/proxmox/import', cfg)
|
||||||
|
mod.proxmoxApi.importToPending(cfg)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/proxmox/import-pending', cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('proxmoxApi.getConfig/saveConfig hit /proxmox/config', () => {
|
||||||
|
mod.proxmoxApi.getConfig()
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/proxmox/config')
|
||||||
|
const conf = { sync_enabled: false, sync_interval: 3600 }
|
||||||
|
mod.proxmoxApi.saveConfig(conf)
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/proxmox/config', conf)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+142
-2
@@ -41,6 +41,17 @@ export const canvasApi = {
|
|||||||
}) => api.post('/canvas/save', payload),
|
}) => api.post('/canvas/save', payload),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const mediaApi = {
|
||||||
|
/** Upload an image, returns its server URL (e.g. /api/v1/media/<uuid>.png). */
|
||||||
|
upload: async (file: File): Promise<{ url: string; filename: string }> => {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const res = await api.post<{ url: string; filename: string }>('/media/upload', form)
|
||||||
|
return res.data
|
||||||
|
},
|
||||||
|
delete: (filename: string) => api.delete(`/media/${filename}`),
|
||||||
|
}
|
||||||
|
|
||||||
export const nodesApi = {
|
export const nodesApi = {
|
||||||
create: (data: object) => api.post('/nodes', data),
|
create: (data: object) => api.post('/nodes', data),
|
||||||
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
|
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
|
||||||
@@ -66,6 +77,26 @@ export interface DeepScanConfig {
|
|||||||
|
|
||||||
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
export type ScanConfigData = { ranges: string[] } & DeepScanConfig
|
||||||
|
|
||||||
|
// A device the backend refused to place because an equivalent node already
|
||||||
|
// exists on the target design (same ip/mac/ieee). `existing_node_id` points at
|
||||||
|
// the node already there so the UI can link to it.
|
||||||
|
export interface SkippedDevice {
|
||||||
|
device_id: string
|
||||||
|
label: string
|
||||||
|
match: 'ip' | 'mac' | 'ieee'
|
||||||
|
value: string
|
||||||
|
existing_node_id: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 409 body from single approve / create when a same-design duplicate is found.
|
||||||
|
export interface DuplicateNodeConflict {
|
||||||
|
duplicate: true
|
||||||
|
existing_node_id: string
|
||||||
|
existing_label: string
|
||||||
|
match: 'ip' | 'mac' | 'ieee'
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
export const scanApi = {
|
export const scanApi = {
|
||||||
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
|
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
|
||||||
pending: () => api.get('/scan/pending'),
|
pending: () => api.get('/scan/pending'),
|
||||||
@@ -77,7 +108,7 @@ export const scanApi = {
|
|||||||
approved: boolean
|
approved: boolean
|
||||||
node_id: string
|
node_id: string
|
||||||
edges_created: number
|
edges_created: number
|
||||||
edges: { id: string; source: string; target: string }[]
|
edges: { id: string; source: string; target: string; type?: string; source_handle?: string | null; target_handle?: string | null }[]
|
||||||
}>(`/scan/pending/${id}/approve`, nodeData),
|
}>(`/scan/pending/${id}/approve`, nodeData),
|
||||||
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
|
||||||
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
|
||||||
@@ -87,8 +118,9 @@ export const scanApi = {
|
|||||||
node_ids: string[]
|
node_ids: string[]
|
||||||
device_ids: string[]
|
device_ids: string[]
|
||||||
edges_created: number
|
edges_created: number
|
||||||
edges: { id: string; source: string; target: string }[]
|
edges: { id: string; source: string; target: string; type?: string; source_handle?: string | null; target_handle?: string | null }[]
|
||||||
skipped: number
|
skipped: number
|
||||||
|
skipped_devices: SkippedDevice[]
|
||||||
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
}>('/scan/pending/bulk-approve', { device_ids: ids, design_id: designId ?? undefined }),
|
||||||
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
bulkHide: (ids: string[]) => api.post<{ hidden: number; skipped: number }>('/scan/pending/bulk-hide', { device_ids: ids }),
|
||||||
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
restore: (id: string) => api.post<{ restored: boolean; device_id: string }>(`/scan/pending/${id}/restore`),
|
||||||
@@ -109,15 +141,109 @@ export const settingsApi = {
|
|||||||
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
save: (data: AppSettings) => api.post<AppSettings>('/settings', data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxConnection {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
token_id?: string
|
||||||
|
token_secret?: string
|
||||||
|
verify_tls?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProxmoxConfigData {
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
verify_tls: boolean
|
||||||
|
sync_enabled: boolean
|
||||||
|
sync_interval: number
|
||||||
|
token_configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const proxmoxApi = {
|
||||||
|
testConnection: (data: ProxmoxConnection) =>
|
||||||
|
api.post<{ connected: boolean; message: string }>('/proxmox/test-connection', data),
|
||||||
|
|
||||||
|
importNetwork: (data: ProxmoxConnection) =>
|
||||||
|
api.post<{
|
||||||
|
nodes: import('@/components/proxmox/types').ProxmoxNode[]
|
||||||
|
edges: import('@/components/proxmox/types').ProxmoxEdge[]
|
||||||
|
device_count: number
|
||||||
|
}>('/proxmox/import', data),
|
||||||
|
|
||||||
|
importToPending: (data: ProxmoxConnection) =>
|
||||||
|
api.post<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/proxmox/import-pending', data),
|
||||||
|
|
||||||
|
getConfig: () => api.get<ProxmoxConfigData>('/proxmox/config'),
|
||||||
|
// Only the auto-sync activation is persisted. Connection config
|
||||||
|
// (host/port/token/verify_tls) is env-only and never sent.
|
||||||
|
saveConfig: (data: { sync_enabled: boolean; sync_interval: number }) =>
|
||||||
|
api.post<ProxmoxConfigData>('/proxmox/config', data),
|
||||||
|
|
||||||
|
syncNow: () =>
|
||||||
|
api.post<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}>('/proxmox/sync-now'),
|
||||||
|
}
|
||||||
|
|
||||||
export const designsApi = {
|
export const designsApi = {
|
||||||
list: () => api.get<import('@/types').Design[]>('/designs'),
|
list: () => api.get<import('@/types').Design[]>('/designs'),
|
||||||
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
create: (data: { name: string; icon?: string; design_type?: string }) =>
|
||||||
api.post<import('@/types').Design>('/designs', data),
|
api.post<import('@/types').Design>('/designs', data),
|
||||||
|
copy: (sourceId: string, data: { name: string; icon?: string }) =>
|
||||||
|
api.post<import('@/types').Design>(`/designs/${sourceId}/copy`, data),
|
||||||
update: (id: string, data: { name?: string; icon?: string }) =>
|
update: (id: string, data: { name?: string; icon?: string }) =>
|
||||||
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
api.put<import('@/types').Design>(`/designs/${id}`, data),
|
||||||
delete: (id: string) => api.delete(`/designs/${id}`),
|
delete: (id: string) => api.delete(`/designs/${id}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ZigbeeConfigData {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
base_topic: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
sync_enabled: boolean
|
||||||
|
sync_interval: number
|
||||||
|
host_configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZwaveConfigData {
|
||||||
|
mqtt_host: string
|
||||||
|
mqtt_port: number
|
||||||
|
prefix: string
|
||||||
|
gateway_name: string
|
||||||
|
mqtt_tls: boolean
|
||||||
|
sync_enabled: boolean
|
||||||
|
sync_interval: number
|
||||||
|
host_configured: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape returned by every background-scan trigger (import-pending / sync-now).
|
||||||
|
interface ScanRunResult {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
kind: string
|
||||||
|
ranges: string[]
|
||||||
|
devices_found: number
|
||||||
|
started_at: string
|
||||||
|
finished_at: string | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const zigbeeApi = {
|
export const zigbeeApi = {
|
||||||
testConnection: (data: {
|
testConnection: (data: {
|
||||||
mqtt_host: string
|
mqtt_host: string
|
||||||
@@ -163,6 +289,13 @@ export const zigbeeApi = {
|
|||||||
finished_at: string | null
|
finished_at: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}>('/zigbee/import-pending', data),
|
}>('/zigbee/import-pending', data),
|
||||||
|
|
||||||
|
getConfig: () => api.get<ZigbeeConfigData>('/zigbee/config'),
|
||||||
|
// Only the auto-sync activation is persisted. MQTT connection config
|
||||||
|
// (host/port/credentials/topic/tls) is env-only and never sent.
|
||||||
|
saveConfig: (data: { sync_enabled: boolean; sync_interval: number }) =>
|
||||||
|
api.post<ZigbeeConfigData>('/zigbee/config', data),
|
||||||
|
syncNow: () => api.post<ScanRunResult>('/zigbee/sync-now'),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const zwaveApi = {
|
export const zwaveApi = {
|
||||||
@@ -212,4 +345,11 @@ export const zwaveApi = {
|
|||||||
finished_at: string | null
|
finished_at: string | null
|
||||||
error: string | null
|
error: string | null
|
||||||
}>('/zwave/import-pending', data),
|
}>('/zwave/import-pending', data),
|
||||||
|
|
||||||
|
getConfig: () => api.get<ZwaveConfigData>('/zwave/config'),
|
||||||
|
// Only the auto-sync activation is persisted. MQTT connection config
|
||||||
|
// (host/port/credentials/prefix/gateway/tls) is env-only and never sent.
|
||||||
|
saveConfig: (data: { sync_enabled: boolean; sync_interval: number }) =>
|
||||||
|
api.post<ZwaveConfigData>('/zwave/config', data),
|
||||||
|
syncNow: () => api.post<ScanRunResult>('/zwave/sync-now'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, migrateClusterHandles, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
@@ -88,10 +88,11 @@ function LiveViewCanvas() {
|
|||||||
const savedTheme = res.data.viewport?.theme_id
|
const savedTheme = res.data.viewport?.theme_id
|
||||||
if (savedTheme) setTheme(savedTheme)
|
if (savedTheme) setTheme(savedTheme)
|
||||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||||
loadCanvas(
|
const migrated = migrateClusterHandles(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
)
|
)
|
||||||
|
loadCanvas(migrated.nodes, migrated.edges)
|
||||||
setViewState('ready')
|
setViewState('ready')
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes'
|
|||||||
import { edgeTypes } from './edges/edgeTypes'
|
import { edgeTypes } from './edges/edgeTypes'
|
||||||
import { SearchBar } from './SearchBar'
|
import { SearchBar } from './SearchBar'
|
||||||
import { AlignmentGuides } from './AlignmentGuides'
|
import { AlignmentGuides } from './AlignmentGuides'
|
||||||
|
import { FloorMapLayer } from './FloorMapLayer'
|
||||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||||
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
||||||
import type { NodeData, EdgeData } from '@/types'
|
import type { NodeData, EdgeData } from '@/types'
|
||||||
@@ -191,6 +192,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
colorMode={theme.colors.reactFlowColorMode}
|
colorMode={theme.colors.reactFlowColorMode}
|
||||||
elevateNodesOnSelect={false}
|
elevateNodesOnSelect={false}
|
||||||
connectionMode={ConnectionMode.Loose}
|
connectionMode={ConnectionMode.Loose}
|
||||||
|
connectionRadius={30}
|
||||||
isValidConnection={isValidConnection}
|
isValidConnection={isValidConnection}
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
@@ -199,6 +201,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
|
<FloorMapLayer />
|
||||||
<SearchBar onOpenPending={onOpenPending} />
|
<SearchBar onOpenPending={onOpenPending} />
|
||||||
<AlignmentGuides guides={guides} />
|
<AlignmentGuides guides={guides} />
|
||||||
<Controls>
|
<Controls>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
interface ResizeState {
|
||||||
|
startMouseX: number
|
||||||
|
startMouseY: number
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
startW: number
|
||||||
|
startH: number
|
||||||
|
edges: Set<'n' | 's' | 'e' | 'w'>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floor plan background rendered INSIDE the React Flow viewport (via
|
||||||
|
* ViewportPortal) so it pans and zooms together with the nodes. Position and
|
||||||
|
* size are stored in flow coordinates.
|
||||||
|
*
|
||||||
|
* It always sits at the bottom of the canvas (behind nodes and edges). When
|
||||||
|
* unlocked it can still be grabbed/resized in areas not covered by a node;
|
||||||
|
* resize handles appear only while it is selected. Double-clicking an unlocked
|
||||||
|
* plan opens its edit modal.
|
||||||
|
*/
|
||||||
|
export function FloorMapLayer() {
|
||||||
|
const floorMap = useCanvasStore((s) => s.floorMap)
|
||||||
|
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
|
||||||
|
const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit)
|
||||||
|
const { screenToFlowPosition } = useReactFlow()
|
||||||
|
const zoom = useStore((s) => s.transform[2])
|
||||||
|
|
||||||
|
const resizeRef = useRef<ResizeState | null>(null)
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selected, setSelected] = useState(false)
|
||||||
|
|
||||||
|
const locked = floorMap?.locked ?? false
|
||||||
|
|
||||||
|
// While selected (and unlocked), deselect on any click outside the plan.
|
||||||
|
// A locked plan can't be selected, and handles/edit are gated on !locked, so
|
||||||
|
// a residual selection is harmless.
|
||||||
|
useEffect(() => {
|
||||||
|
if (locked || !selected) return
|
||||||
|
const onDocDown = (ev: MouseEvent) => {
|
||||||
|
if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocDown)
|
||||||
|
return () => document.removeEventListener('mousedown', onDocDown)
|
||||||
|
}, [selected, locked])
|
||||||
|
|
||||||
|
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (!floorMap) return
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelected(true)
|
||||||
|
const startX = e.clientX
|
||||||
|
const startY = e.clientY
|
||||||
|
const origPosX = floorMap.posX
|
||||||
|
const origPosY = floorMap.posY
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const start = screenToFlowPosition({ x: startX, y: startY })
|
||||||
|
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
||||||
|
updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) })
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
||||||
|
|
||||||
|
const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => {
|
||||||
|
if (!floorMap) return
|
||||||
|
e.stopPropagation()
|
||||||
|
resizeRef.current = {
|
||||||
|
startMouseX: e.clientX,
|
||||||
|
startMouseY: e.clientY,
|
||||||
|
startX: floorMap.posX,
|
||||||
|
startY: floorMap.posY,
|
||||||
|
startW: floorMap.width,
|
||||||
|
startH: floorMap.height,
|
||||||
|
edges,
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
const rs = resizeRef.current
|
||||||
|
if (!rs) return
|
||||||
|
const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY })
|
||||||
|
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
|
||||||
|
const dx = cur.x - start.x
|
||||||
|
const dy = cur.y - start.y
|
||||||
|
let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH
|
||||||
|
if (rs.edges.has('w')) { x += dx; w -= dx }
|
||||||
|
if (rs.edges.has('e')) w += dx
|
||||||
|
if (rs.edges.has('n')) { y += dy; h -= dy }
|
||||||
|
if (rs.edges.has('s')) h += dy
|
||||||
|
const MIN = 80
|
||||||
|
if (w < MIN) {
|
||||||
|
if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN
|
||||||
|
w = MIN
|
||||||
|
}
|
||||||
|
if (h < MIN) {
|
||||||
|
if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN
|
||||||
|
h = MIN
|
||||||
|
}
|
||||||
|
updateFloorMap({ posX: x, posY: y, width: w, height: h })
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
resizeRef.current = null
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}, [floorMap, updateFloorMap, screenToFlowPosition])
|
||||||
|
|
||||||
|
if (!floorMap || !floorMap.enabled) return null
|
||||||
|
|
||||||
|
const { imageData, posX, posY, width, height, opacity } = floorMap
|
||||||
|
|
||||||
|
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
|
||||||
|
// on-screen size regardless of the current zoom level.
|
||||||
|
const hsz = 10 / zoom
|
||||||
|
const half = hsz / 2
|
||||||
|
const hs: React.CSSProperties = {
|
||||||
|
position: 'absolute',
|
||||||
|
width: hsz,
|
||||||
|
height: hsz,
|
||||||
|
background: '#00d4ff',
|
||||||
|
border: `${2 / zoom}px solid #0d1117`,
|
||||||
|
borderRadius: 2 / zoom,
|
||||||
|
zIndex: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ViewportPortal>
|
||||||
|
<div
|
||||||
|
ref={wrapperRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: posX,
|
||||||
|
top: posY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
opacity,
|
||||||
|
// Always at the bottom of the canvas, behind nodes and edges.
|
||||||
|
zIndex: -1,
|
||||||
|
pointerEvents: locked ? 'none' : 'auto',
|
||||||
|
cursor: locked ? 'default' : 'move',
|
||||||
|
}}
|
||||||
|
onMouseDown={locked ? undefined : onDragStart}
|
||||||
|
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageData}
|
||||||
|
alt="Floor plan"
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'fill',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!locked && selected && (
|
||||||
|
<>
|
||||||
|
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'ne-resize', top: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'se-resize', bottom: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
|
||||||
|
<div style={{ ...hs, cursor: 's-resize', bottom: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'sw-resize', bottom: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
|
||||||
|
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ViewportPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -43,15 +43,45 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const q = query.toLowerCase().trim()
|
const q = query.toLowerCase().trim()
|
||||||
|
|
||||||
const nodeResults = q
|
const nodeResults = q
|
||||||
? nodes.filter((n) => {
|
? nodes.flatMap((n) => {
|
||||||
if (n.data.type === 'groupRect') return false
|
if (n.data.type === 'groupRect') return []
|
||||||
return (
|
|
||||||
n.data.label?.toLowerCase().includes(q) ||
|
const contains = (v?: string | null) => v?.toLowerCase().includes(q)
|
||||||
n.data.ip?.toLowerCase().includes(q) ||
|
|
||||||
n.data.hostname?.toLowerCase().includes(q) ||
|
const matches: Array<{ key: string; display_value: string }> = []
|
||||||
(n.data.services ?? []).some((s) => s.service_name?.toLowerCase().includes(q))
|
|
||||||
)
|
// Label matches make the node a result, but it is already shown in the
|
||||||
|
// header — don't repeat it in the matched-values column.
|
||||||
|
const labelMatch = contains(n.data.label)
|
||||||
|
|
||||||
|
for (const [key, value] of [
|
||||||
|
['notes', n.data.notes],
|
||||||
|
['ip', n.data.ip],
|
||||||
|
['hostname', n.data.hostname],
|
||||||
|
] as const) {
|
||||||
|
if (contains(value)) {
|
||||||
|
matches.push({ key, display_value: value ?? '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of n.data.services ?? []) {
|
||||||
|
if (contains(s.service_name)) {
|
||||||
|
matches.push({ key: 'services', display_value: s.service_name ?? '' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of (n.data.properties ?? []).filter((p) => p.visible)) {
|
||||||
|
if (contains(p.key) || contains(p.value)) {
|
||||||
|
matches.push({
|
||||||
|
key: 'properties',
|
||||||
|
display_value: `${p.key ?? ''} = ${p.value ?? ''}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelMatch || matches.length > 0 ? [{ node: n, matches }] : []
|
||||||
})
|
})
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -142,10 +172,10 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
|
|
||||||
{totalResults > 0 && (
|
{totalResults > 0 && (
|
||||||
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
<div style={{ borderTop: '1px solid #30363d', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
{nodeResults.map((n) => (
|
{nodeResults.map((n, idx) => (
|
||||||
<button
|
<button
|
||||||
key={n.id}
|
key={`${n.node.id}-${idx}`}
|
||||||
onClick={() => goToNode(n.id)}
|
onClick={() => goToNode(n.node.id)}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -161,15 +191,19 @@ export function SearchBar({ onOpenPending }: SearchBarProps) {
|
|||||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||||
>
|
>
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ fontSize: 12, fontWeight: 600, color: '#e6edf3', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{n.data.label}
|
{n.node.data.label}
|
||||||
</span>
|
</span>
|
||||||
{n.data.ip && (
|
{n.matches.length > 0 && (
|
||||||
<span style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0 }}>
|
<span style={{ maxWidth: "50%", display: 'flex', flexDirection: 'column', gap: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{n.data.ip}
|
{n.matches.map((m, mIdx) => (
|
||||||
|
<span key={`${m.key}-${mIdx}`} style={{ fontSize: 11, color: '#8b949e', fontFamily: 'JetBrains Mono, monospace', flexShrink: 0, display: 'inline-block' }}>
|
||||||
|
{m.display_value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
<span style={{ fontSize: 10, color: '#6e7681', flexShrink: 0 }}>
|
||||||
{NODE_TYPE_LABELS[n.data.type] ?? n.data.type}
|
{NODE_TYPE_LABELS[n.node.data.type] ?? n.node.data.type}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ let mockZoom = 1
|
|||||||
|
|
||||||
vi.mock('@xyflow/react', () => ({
|
vi.mock('@xyflow/react', () => ({
|
||||||
Handle: () => null,
|
Handle: () => null,
|
||||||
Position: { Top: 'top', Bottom: 'bottom' },
|
Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
|
||||||
NodeResizer: () => null,
|
NodeResizer: () => null,
|
||||||
useUpdateNodeInternals: () => vi.fn(),
|
useUpdateNodeInternals: () => vi.fn(),
|
||||||
useViewport: () => ({ zoom: mockZoom }),
|
useViewport: () => ({ zoom: mockZoom }),
|
||||||
@@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({
|
|||||||
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/utils/handleUtils', () => ({
|
// handleUtils is pure — use the real implementation so the side-generic API
|
||||||
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
|
// (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync.
|
||||||
bottomHandlePositions: (count: number) => {
|
vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal())
|
||||||
const c = typeof count === 'number' && count > 0 ? Math.floor(count) : 1
|
|
||||||
return Array.from({ length: c }, (_, i) => ((i + 1) * 100) / (c + 1))
|
|
||||||
},
|
|
||||||
clampBottomHandles: (n: unknown) => typeof n === 'number' ? n : 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
beforeEach(() => { mockZoom = 1 })
|
beforeEach(() => { mockZoom = 1 })
|
||||||
|
|
||||||
@@ -124,6 +119,14 @@ describe('BaseNode — borderWidth zoom scaling', () => {
|
|||||||
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
expect((container.firstChild as HTMLElement).style.borderWidth).toBe('1px')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Regression: the node root must NOT clip its own bounds, otherwise the outer
|
||||||
|
// half of each connection handle (which sits centred on the edge) becomes
|
||||||
|
// non-interactive and the "magnet" snap area is halved.
|
||||||
|
it('root does not clip overflow (keeps handles grabbable)', () => {
|
||||||
|
const { container } = renderBaseNode({})
|
||||||
|
expect((container.firstChild as HTMLElement).className).not.toContain('overflow-hidden')
|
||||||
|
})
|
||||||
|
|
||||||
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
it('boxShadow glow ring uses borderWidth when selected + online at zoom=0.5', () => {
|
||||||
mockZoom = 0.5
|
mockZoom = 0.5
|
||||||
const node = makeNode({ status: 'online' })
|
const node = makeNode({ status: 'online' })
|
||||||
@@ -176,15 +179,23 @@ describe('BaseNode — properties rendering', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BaseNode — port numbers (issue #20)', () => {
|
describe('BaseNode — port numbers (issue #20 / #243)', () => {
|
||||||
it('renders a number above each bottom handle when show_port_numbers is on', () => {
|
it('renders a number on each connection point when show_port_numbers is on', () => {
|
||||||
|
// bottom=4 → labels 1..4; top defaults to 1 → an extra "1" on top.
|
||||||
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
renderBaseNode({ bottom_handles: 4, show_port_numbers: true })
|
||||||
expect(screen.getByText('1')).toBeDefined()
|
expect(screen.getAllByText('1')).toHaveLength(2) // top slot 0 + bottom slot 0
|
||||||
expect(screen.getByText('2')).toBeDefined()
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
expect(screen.getByText('3')).toBeDefined()
|
expect(screen.getByText('3')).toBeDefined()
|
||||||
expect(screen.getByText('4')).toBeDefined()
|
expect(screen.getByText('4')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('labels left/right connection points too when enabled', () => {
|
||||||
|
// top=1, bottom=1, left=2, right=0 → labels: two "1" (top+bottom) + "2" (left).
|
||||||
|
renderBaseNode({ bottom_handles: 1, left_handles: 2, show_port_numbers: true })
|
||||||
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
|
expect(screen.getAllByText('1')).toHaveLength(3) // top + bottom + left slot 0
|
||||||
|
})
|
||||||
|
|
||||||
it('does not render port numbers when show_port_numbers is off', () => {
|
it('does not render port numbers when show_port_numbers is off', () => {
|
||||||
renderBaseNode({ bottom_handles: 4 })
|
renderBaseNode({ bottom_handles: 4 })
|
||||||
expect(screen.queryByText('1')).toBeNull()
|
expect(screen.queryByText('1')).toBeNull()
|
||||||
@@ -192,8 +203,9 @@ describe('BaseNode — port numbers (issue #20)', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('numbers match the handle count', () => {
|
it('numbers match the handle count', () => {
|
||||||
|
// bottom=2 → 1,2; top default 1 adds one more "1"; no "3".
|
||||||
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
renderBaseNode({ bottom_handles: 2, show_port_numbers: true })
|
||||||
expect(screen.getByText('1')).toBeDefined()
|
expect(screen.getAllByText('1')).toHaveLength(2)
|
||||||
expect(screen.getByText('2')).toBeDefined()
|
expect(screen.getByText('2')).toBeDefined()
|
||||||
expect(screen.queryByText('3')).toBeNull()
|
expect(screen.queryByText('3')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
import { FloorMapLayer } from '../FloorMapLayer'
|
||||||
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import type { FloorMapConfig } from '@/types'
|
||||||
|
|
||||||
|
// Stub React Flow: render the portal inline, and give the layer a 1x zoom and
|
||||||
|
// an identity screen→flow projection so it can mount without a provider.
|
||||||
|
vi.mock('@xyflow/react', () => ({
|
||||||
|
ViewportPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
useReactFlow: () => ({ screenToFlowPosition: (p: { x: number; y: number }) => p }),
|
||||||
|
useStore: (sel: (s: { transform: number[] }) => unknown) => sel({ transform: [0, 0, 1] }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const BASE: FloorMapConfig = {
|
||||||
|
imageData: '/api/v1/media/abc.png',
|
||||||
|
posX: 0, posY: 0, width: 800, height: 600,
|
||||||
|
opacity: 0.8, locked: false, enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFloorMap(patch: Partial<FloorMapConfig> = {}) {
|
||||||
|
useCanvasStore.setState({ floorMap: { ...BASE, ...patch }, floorMapEditNonce: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapper() {
|
||||||
|
return screen.getByAltText('Floor plan').parentElement as HTMLElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCount(root: HTMLElement) {
|
||||||
|
return Array.from(root.querySelectorAll('div')).filter((d) =>
|
||||||
|
(d.getAttribute('style') ?? '').includes('resize'),
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FloorMapLayer', () => {
|
||||||
|
beforeEach(() => useCanvasStore.setState({ floorMap: null, floorMapEditNonce: 0 }))
|
||||||
|
|
||||||
|
it('renders nothing when there is no plan or it is disabled', () => {
|
||||||
|
const { container, rerender } = render(<FloorMapLayer />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
setFloorMap({ enabled: false })
|
||||||
|
rerender(<FloorMapLayer />)
|
||||||
|
expect(container.querySelector('img')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
expect(handleCount(wrapper())).toBe(0)
|
||||||
|
|
||||||
|
fireEvent.mouseDown(wrapper())
|
||||||
|
expect(handleCount(wrapper())).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never shows handles and is non-interactive when locked', () => {
|
||||||
|
setFloorMap({ locked: true })
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
const w = wrapper()
|
||||||
|
fireEvent.mouseDown(w)
|
||||||
|
expect(handleCount(w)).toBe(0)
|
||||||
|
expect(w.style.pointerEvents).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('double-click on an unlocked plan requests the edit modal', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
fireEvent.doubleClick(wrapper())
|
||||||
|
expect(useCanvasStore.getState().floorMapEditNonce).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('locked plan ignores double-click', () => {
|
||||||
|
setFloorMap({ locked: true })
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
fireEvent.doubleClick(wrapper())
|
||||||
|
expect(useCanvasStore.getState().floorMapEditNonce).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sits at the bottom of the canvas (negative z-index)', () => {
|
||||||
|
setFloorMap()
|
||||||
|
render(<FloorMapLayer />)
|
||||||
|
expect(wrapper().style.zIndex).toBe('-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -103,6 +103,50 @@ describe('SearchBar', () => {
|
|||||||
expect(screen.queryByText('DB Server')).toBeNull()
|
expect(screen.queryByText('DB Server')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('filters by notes', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'backup target every night' } }),
|
||||||
|
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, notes: 'primary web host' } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'backup' } })
|
||||||
|
expect(screen.getByText('Server A')).toBeDefined()
|
||||||
|
expect(screen.queryByText('Server B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by visible property key or value', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }),
|
||||||
|
makeNode('n2', { data: { label: 'Server B', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'A01', icon: null, visible: true }] } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } })
|
||||||
|
expect(screen.getByText('Server A')).toBeDefined()
|
||||||
|
expect(screen.queryByText('Server B')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores hidden properties', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'secret', value: 'hidden-val', icon: null, visible: false }] } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'hidden-val' } })
|
||||||
|
expect(screen.queryByText('Server A')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the matched value alongside the node label', () => {
|
||||||
|
setupStore([
|
||||||
|
makeNode('n1', { data: { label: 'Server A', type: 'server', status: 'online', services: [], ip: null, hostname: null, properties: [{ key: 'rack', value: 'B12', icon: null, visible: true }] } }),
|
||||||
|
])
|
||||||
|
render(<SearchBar />)
|
||||||
|
openSearch()
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'b12' } })
|
||||||
|
expect(screen.getByText('rack = B12')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('excludes groupRect nodes from results', () => {
|
it('excludes groupRect nodes from results', () => {
|
||||||
setupStore([
|
setupStore([
|
||||||
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
|
makeNode('gr1', { data: { label: 'DMZ Zone', type: 'groupRect', status: 'unknown', services: [], ip: null, hostname: null } }),
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import { HomelableEdge } from '../index'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-edge line render: `line_style` overrides the type's dash preset and
|
||||||
|
* `width_mult` scales the type base stroke width (1×–4×). Both are optional —
|
||||||
|
* unset leaves the edge type's default look untouched.
|
||||||
|
*/
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The BaseEdge path is the one carrying the interaction width. */
|
||||||
|
function edgePath(container: HTMLElement): SVGPathElement {
|
||||||
|
return container.querySelector('path.react-flow__edge-path') as SVGPathElement
|
||||||
|
?? (container.querySelector('path') as SVGPathElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge line style + width', () => {
|
||||||
|
it('scales stroke width by the multiplier (ethernet base 2 × 3 = 6)', () => {
|
||||||
|
const { container } = renderEdge({ width_mult: 3 })
|
||||||
|
expect(edgePath(container).style.strokeWidth).toBe('6')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the base width when no multiplier is set', () => {
|
||||||
|
const { container } = renderEdge()
|
||||||
|
expect(edgePath(container).style.strokeWidth).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies a dash pattern for a dashed line style', () => {
|
||||||
|
const { container } = renderEdge({ line_style: 'dashed', width_mult: 2 })
|
||||||
|
// width 4 → dashed "12 8"
|
||||||
|
expect(edgePath(container).style.strokeDasharray.replace(/,/g, '')).toBe('12 8')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a round cap for dotted lines', () => {
|
||||||
|
const { container } = renderEdge({ line_style: 'dotted' })
|
||||||
|
expect(edgePath(container).style.strokeLinecap).toBe('round')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the preset dash for a solid override', () => {
|
||||||
|
// wifi defaults to a dashed preset; solid override removes it
|
||||||
|
const { container } = renderEdge({ type: 'wifi', line_style: 'solid' })
|
||||||
|
expect(edgePath(container).style.strokeDasharray).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import { HomelableEdge } from '../index'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Endpoint markers: per-end shape (arrow / arrow-open / circle / diamond /
|
||||||
|
* square) <marker> defs, independently selectable at start/end, filled with the
|
||||||
|
* live stroke color, referenced by BaseEdge via markerStart/markerEnd URLs.
|
||||||
|
* Legacy boolean values coerce to the filled 'arrow' shape.
|
||||||
|
*/
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}, selected = false) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge arrow markers', () => {
|
||||||
|
it('renders no marker defs by default', () => {
|
||||||
|
const { container } = renderEdge()
|
||||||
|
expect(container.querySelector('marker')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders an end marker referenced by the edge path', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'arrow' })
|
||||||
|
const marker = container.querySelector('#arrow-end-e1')
|
||||||
|
expect(marker).toBeTruthy()
|
||||||
|
expect(container.querySelector('#arrow-start-e1')).toBeNull()
|
||||||
|
const referenced = Array.from(container.querySelectorAll('path')).some(
|
||||||
|
(p) => p.getAttribute('marker-end') === 'url(#arrow-end-e1)',
|
||||||
|
)
|
||||||
|
expect(referenced).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces a legacy boolean marker to the filled arrow shape', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: true })
|
||||||
|
const path = container.querySelector('#arrow-end-e1 path')
|
||||||
|
expect(path?.getAttribute('d')).toBe('M 0 0 L 10 5 L 0 10 z')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a directional start marker with reversed orientation', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'arrow' })
|
||||||
|
const marker = container.querySelector('#arrow-start-e1')
|
||||||
|
expect(marker).toBeTruthy()
|
||||||
|
expect(marker?.getAttribute('orient')).toBe('auto-start-reverse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a circle marker as a <circle>, not a triangle', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'circle' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1 circle')).toBeTruthy()
|
||||||
|
expect(container.querySelector('#arrow-end-e1 path')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a square marker as a <rect>', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'square' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1 rect')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses fixed orientation for symmetric shapes', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'circle' })
|
||||||
|
expect(container.querySelector('#arrow-end-e1')?.getAttribute('orient')).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports different shapes on each end', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'diamond', marker_end: 'arrow-open' })
|
||||||
|
// diamond is a filled path
|
||||||
|
const startPath = container.querySelector('#arrow-start-e1 path')
|
||||||
|
expect(startPath?.getAttribute('d')).toContain('9.5')
|
||||||
|
expect(startPath?.getAttribute('fill')).not.toBe('none')
|
||||||
|
// arrow-open is stroked, not filled
|
||||||
|
expect(container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders both markers when both ends enabled', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'arrow', marker_end: 'arrow' })
|
||||||
|
expect(container.querySelector('#arrow-start-e1')).toBeTruthy()
|
||||||
|
expect(container.querySelector('#arrow-end-e1')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no marker for the "none" shape', () => {
|
||||||
|
const { container } = renderEdge({ marker_start: 'none', marker_end: 'none' })
|
||||||
|
expect(container.querySelector('marker')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fills the marker with the resolved custom color', () => {
|
||||||
|
const { container } = renderEdge({ marker_end: 'arrow', custom_color: '#ff6e00' })
|
||||||
|
const fill = container.querySelector('#arrow-end-e1 path')?.getAttribute('fill')
|
||||||
|
expect(fill).toBe('#ff6e00')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -9,10 +9,12 @@ import {
|
|||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
type Edge,
|
type Edge,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import type { EdgeData, EdgeType, Waypoint } from '@/types'
|
import type { EdgeData, EdgeLineStyle, EdgeType, Waypoint } from '@/types'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
|
import { MARKER_GEOMETRY, normalizeMarker, type NonNoneMarkerShape } from '@/utils/edgeMarkers'
|
||||||
|
import { clampWidthMult, dashArrayFor } from '@/utils/edgeLineStyle'
|
||||||
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
import { buildWaypointPath, getAddWaypointHandlePosition, getWaypointLabelPosition, snap45, snap45both } from './waypointUtils'
|
||||||
|
|
||||||
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
const VLAN_COLORS = ['#00d4ff', '#a855f7', '#39d353', '#ff6e00', '#e3b341', '#f85149']
|
||||||
@@ -22,6 +24,22 @@ function getVlanColor(vlanId?: number): string {
|
|||||||
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
return VLAN_COLORS[vlanId % VLAN_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inner SVG element for an edge <marker>, drawn in a 0..10 viewBox. */
|
||||||
|
function markerInnerElement(shape: NonNoneMarkerShape, color: string): React.ReactElement {
|
||||||
|
switch (shape) {
|
||||||
|
case 'arrow':
|
||||||
|
return <path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
|
||||||
|
case 'arrow-open':
|
||||||
|
return <path d="M 1 1 L 9 5 L 1 9" fill="none" stroke={color} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
case 'circle':
|
||||||
|
return <circle cx={5} cy={5} r={4} fill={color} />
|
||||||
|
case 'diamond':
|
||||||
|
return <path d="M 5 0.5 L 9.5 5 L 5 9.5 L 0.5 5 z" fill={color} />
|
||||||
|
case 'square':
|
||||||
|
return <rect x={1} y={1} width={8} height={8} fill={color} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
// ── Waypoint drag handle ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface WaypointHandleProps {
|
interface WaypointHandleProps {
|
||||||
@@ -333,8 +351,23 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
: customColor
|
: customColor
|
||||||
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet))
|
||||||
|
|
||||||
|
// Per-edge line render overrides (custom style editor). Width multiplies the
|
||||||
|
// type's base width; line style overrides the preset dash pattern. Both are
|
||||||
|
// optional — unset leaves the type default from BASE_STYLES untouched.
|
||||||
|
const baseWidth = (BASE_STYLES[edgeType].strokeWidth as number) ?? 2
|
||||||
|
const widthMult = clampWidthMult(data?.width_mult as number | undefined)
|
||||||
|
const resolvedWidth = baseWidth * widthMult
|
||||||
|
const lineStyleOverride = data?.line_style as EdgeLineStyle | undefined
|
||||||
|
|
||||||
const style: React.CSSProperties = {
|
const style: React.CSSProperties = {
|
||||||
...BASE_STYLES[edgeType],
|
...BASE_STYLES[edgeType],
|
||||||
|
strokeWidth: resolvedWidth,
|
||||||
|
...(lineStyleOverride
|
||||||
|
? {
|
||||||
|
strokeDasharray: dashArrayFor(lineStyleOverride, resolvedWidth),
|
||||||
|
strokeLinecap: lineStyleOverride === 'dotted' ? 'round' : 'butt',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}),
|
||||||
...(customColor ? { stroke: customColor } : {}),
|
...(customColor ? { stroke: customColor } : {}),
|
||||||
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}),
|
||||||
@@ -351,9 +384,54 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
? segmentMidpoints(sourceX, sourceY, waypoints, targetX, targetY, pathStyle, sourcePosition)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
|
// ── Endpoint markers ───────────────────────────────────────────────────────
|
||||||
|
// Custom inline <marker> defs filled with the live strokeColor so they recolor
|
||||||
|
// reactively (custom_color / vlan / selected). Sized from the stroke width.
|
||||||
|
// Each end picks its own shape (arrow / arrow-open / circle / diamond / square)
|
||||||
|
// independently; 'none' renders no marker.
|
||||||
|
const startShape = normalizeMarker(data?.marker_start)
|
||||||
|
const endShape = normalizeMarker(data?.marker_end)
|
||||||
|
const hasMarkers = startShape !== 'none' || endShape !== 'none'
|
||||||
|
const strokeW = (style.strokeWidth as number) ?? 2
|
||||||
|
const markerSize = 6 + strokeW * 2
|
||||||
|
const startMarkerId = `arrow-start-${id}`
|
||||||
|
const endMarkerId = `arrow-end-${id}`
|
||||||
|
|
||||||
|
const arrowMarker = (markerId: string, shape: NonNoneMarkerShape, orient: string) => {
|
||||||
|
const geo = MARKER_GEOMETRY[shape]
|
||||||
|
return (
|
||||||
|
<marker
|
||||||
|
id={markerId}
|
||||||
|
viewBox="0 0 10 10"
|
||||||
|
refX={geo.refX}
|
||||||
|
refY={5}
|
||||||
|
markerWidth={markerSize}
|
||||||
|
markerHeight={markerSize}
|
||||||
|
markerUnits="userSpaceOnUse"
|
||||||
|
orient={geo.directional ? orient : '0'}
|
||||||
|
>
|
||||||
|
{markerInnerElement(shape, strokeColor)}
|
||||||
|
</marker>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<BaseEdge id={id} path={edgePath} style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style} interactionWidth={16} />
|
{hasMarkers && (
|
||||||
|
<defs>
|
||||||
|
{startShape !== 'none' && arrowMarker(startMarkerId, startShape, 'auto-start-reverse')}
|
||||||
|
{endShape !== 'none' && arrowMarker(endMarkerId, endShape, 'auto')}
|
||||||
|
</defs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<BaseEdge
|
||||||
|
id={id}
|
||||||
|
path={edgePath}
|
||||||
|
style={animMode === 'basic' ? { ...style, stroke: 'transparent' } : style}
|
||||||
|
interactionWidth={16}
|
||||||
|
markerStart={startShape !== 'none' ? `url(#${startMarkerId})` : undefined}
|
||||||
|
markerEnd={endShape !== 'none' ? `url(#${endMarkerId})` : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
{animMode === 'basic' && (
|
{animMode === 'basic' && (
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useEffect, useMemo } from 'react'
|
import { createElement, useEffect, useMemo } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
import { NodeResizer, useUpdateNodeInternals, useViewport, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
import { Cpu, MemoryStick, HardDrive, ExternalLink, type LucideIcon } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore'
|
|||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
|
||||||
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, primaryIp, splitIps } from '@/utils/maskIp'
|
||||||
import { bottomHandleId, bottomHandlePositions, clampBottomHandles } from '@/utils/handleUtils'
|
import { sideHandleCount } from '@/utils/handleUtils'
|
||||||
|
import { SideHandles } from './SideHandles'
|
||||||
import { getServiceUrl } from '@/utils/serviceUrl'
|
import { getServiceUrl } from '@/utils/serviceUrl'
|
||||||
|
|
||||||
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
interface BaseNodeProps extends NodeProps<Node<NodeData>> {
|
||||||
@@ -24,7 +25,7 @@ function formatStorage(gb: number): string {
|
|||||||
|
|
||||||
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
export function BaseNode({ id, data, selected, icon: typeIcon, width, height }: BaseNodeProps) {
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const { zoom } = useViewport()
|
const { zoom } = useViewport()
|
||||||
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
const borderWidth = useMemo(() => Math.max(1, 1 / zoom), [zoom])
|
||||||
@@ -47,9 +48,15 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
const showLegacyHardware = !data.properties && data.show_hardware &&
|
const showLegacyHardware = !data.properties && data.show_hardware &&
|
||||||
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
(data.cpu_count != null || data.cpu_model || data.ram_gb != null || data.disk_gb != null)
|
||||||
|
|
||||||
|
// Resolved per-side connection-point counts (missing field → side default).
|
||||||
|
const topCount = sideHandleCount(data, 'top')
|
||||||
|
const bottomCount = sideHandleCount(data, 'bottom')
|
||||||
|
const leftCount = sideHandleCount(data, 'left')
|
||||||
|
const rightCount = sideHandleCount(data, 'right')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col rounded-lg border transition-all duration-200 overflow-hidden"
|
className="relative flex flex-col rounded-lg border transition-all duration-200"
|
||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
@@ -62,8 +69,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
? `0 0 0 ${borderWidth}px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
// Grow node width when many bottom handles so each stays clickable (~14px slot).
|
// Grow node so each handle stays clickable (~14px slot on each axis).
|
||||||
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
|
minWidth: Math.max(140, Math.max(topCount, bottomCount) * 14),
|
||||||
|
minHeight: Math.max(50, Math.max(leftCount, rightCount) * 14),
|
||||||
width: width ? '100%' : undefined,
|
width: width ? '100%' : undefined,
|
||||||
height: height ? '100%' : undefined,
|
height: height ? '100%' : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -73,15 +81,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: 'transparent' }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 16, height: 16 }}
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8, borderRadius: 2 }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<SideHandles
|
||||||
type="source"
|
data={data}
|
||||||
position={Position.Top}
|
sides={['top', 'left', 'right']}
|
||||||
id="top"
|
handleBackground={theme.colors.handleBackground}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
handleBorder={theme.colors.handleBorder}
|
||||||
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
|
showLabels
|
||||||
/>
|
/>
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
|
||||||
|
|
||||||
{/* Status dot — absolute to avoid affecting node auto-width */}
|
{/* Status dot — absolute to avoid affecting node auto-width */}
|
||||||
<div
|
<div
|
||||||
@@ -251,40 +260,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
<SideHandles
|
||||||
const sourceId = bottomHandleId(idx)
|
data={data}
|
||||||
const targetId = `${sourceId}-t`
|
sides={['bottom']}
|
||||||
return (
|
handleBackground={theme.colors.handleBackground}
|
||||||
<span key={sourceId}>
|
handleBorder={theme.colors.handleBorder}
|
||||||
{data.show_port_numbers && (
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
<span
|
showLabels
|
||||||
className="absolute font-mono leading-none pointer-events-none select-none"
|
/>
|
||||||
style={{
|
|
||||||
left: `${leftPct}%`,
|
|
||||||
bottom: 3,
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
fontSize: 7,
|
|
||||||
color: theme.colors.nodeSubtextColor,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={sourceId}
|
|
||||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={targetId}
|
|
||||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createElement, useEffect } from 'react'
|
import { createElement, useEffect } from 'react'
|
||||||
import { Handle, Position, NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
import { NodeResizer, useUpdateNodeInternals, type NodeProps, type Node } from '@xyflow/react'
|
||||||
import { Layers } from 'lucide-react'
|
import { Layers } from 'lucide-react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData } from '@/types'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
@@ -10,47 +10,31 @@ import { useCanvasStore } from '@/stores/canvasStore'
|
|||||||
import { maskIp, splitIps } from '@/utils/maskIp'
|
import { maskIp, splitIps } from '@/utils/maskIp'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
|
|
||||||
import { BaseNode } from './BaseNode'
|
import { BaseNode } from './BaseNode'
|
||||||
|
import { SideHandles } from './SideHandles'
|
||||||
|
|
||||||
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
||||||
const { id, data, selected } = props
|
const { id, data, selected } = props
|
||||||
const updateNodeInternals = useUpdateNodeInternals()
|
const updateNodeInternals = useUpdateNodeInternals()
|
||||||
useEffect(() => { updateNodeInternals(id) }, [data.bottom_handles, id, updateNodeInternals])
|
useEffect(() => { updateNodeInternals(id) }, [data.top_handles, data.bottom_handles, data.left_handles, data.right_handles, id, updateNodeInternals])
|
||||||
|
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
const colors = resolveNodeColors(data, activeTheme)
|
const colors = resolveNodeColors(data, activeTheme)
|
||||||
|
|
||||||
// Render as a regular node when container mode is disabled
|
// Container mode is opt-in — a proxmox node renders as a regular card unless
|
||||||
if (data.container_mode === false) {
|
// it is explicitly a container (matches the rest of the codebase, which gates
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
// nesting on `container_mode === true`; see App.tsx). Imported nodes leave the
|
||||||
return (
|
// flag unset and so render like a manually-created proxmox node. Cluster links
|
||||||
<>
|
// use the configurable per-side connection points (see BaseNode / SideHandles).
|
||||||
<BaseNode {...props} icon={Layers} />
|
if (data.container_mode !== true) {
|
||||||
<Handle
|
return <BaseNode {...props} icon={Layers} />
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="cluster-left"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="cluster-right"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = theme.colors.statusColors[data.status]
|
const statusColor = theme.colors.statusColors[data.status]
|
||||||
const isOnline = data.status === 'online'
|
const isOnline = data.status === 'online'
|
||||||
const glow = colors.border
|
const glow = colors.border
|
||||||
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
|
|
||||||
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,48 +129,11 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
|
|||||||
<div className="flex-1 relative" />
|
<div className="flex-1 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Handle
|
<SideHandles
|
||||||
type="source"
|
data={data}
|
||||||
position={Position.Top}
|
handleBackground={theme.colors.handleBackground}
|
||||||
id="top"
|
handleBorder={theme.colors.handleBorder}
|
||||||
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
labelColor={theme.colors.nodeSubtextColor}
|
||||||
/>
|
|
||||||
<Handle type="target" position={Position.Top} id="top-t" style={{ opacity: 0, width: 12, height: 12 }} />
|
|
||||||
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
|
|
||||||
const sourceId = bottomHandleId(idx)
|
|
||||||
const targetId = `${sourceId}-t`
|
|
||||||
return (
|
|
||||||
<span key={sourceId}>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={sourceId}
|
|
||||||
style={{ left: `${leftPct}%`, background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="target"
|
|
||||||
position={Position.Bottom}
|
|
||||||
id={targetId}
|
|
||||||
style={{ left: `${leftPct}%`, opacity: 0, width: 12, height: 12 }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Cluster handles */}
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Left}
|
|
||||||
id="cluster-left"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
|
||||||
<Handle
|
|
||||||
type="source"
|
|
||||||
position={Position.Right}
|
|
||||||
id="cluster-right"
|
|
||||||
title="Same cluster"
|
|
||||||
style={{ background: proxmoxAccent, borderColor: `${proxmoxAccent}88`, width: 6, height: 6 }}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { CSSProperties } from 'react'
|
||||||
|
import { Handle, Position } from '@xyflow/react'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
import {
|
||||||
|
SIDES,
|
||||||
|
handleId,
|
||||||
|
handlePositions,
|
||||||
|
isVerticalSide,
|
||||||
|
sideHandleCount,
|
||||||
|
type Side,
|
||||||
|
} from '@/utils/handleUtils'
|
||||||
|
|
||||||
|
const POSITION: Record<Side, Position> = {
|
||||||
|
top: Position.Top,
|
||||||
|
bottom: Position.Bottom,
|
||||||
|
left: Position.Left,
|
||||||
|
right: Position.Right,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SideHandlesProps {
|
||||||
|
data: NodeData
|
||||||
|
handleBackground: string
|
||||||
|
handleBorder: string
|
||||||
|
/** Colour for the optional port-number labels. */
|
||||||
|
labelColor: string
|
||||||
|
/** Which sides to render. Defaults to all four. */
|
||||||
|
sides?: readonly Side[]
|
||||||
|
/** When true, render port-number labels if data.show_port_numbers is set. */
|
||||||
|
showLabels?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the per-side React Flow handles (visible source + invisible target)
|
||||||
|
* for a node, spaced along each side's axis. Shared by BaseNode and the
|
||||||
|
* container-mode ProxmoxGroupNode so handle IDs stay identical across both.
|
||||||
|
*/
|
||||||
|
export function SideHandles({
|
||||||
|
data,
|
||||||
|
handleBackground,
|
||||||
|
handleBorder,
|
||||||
|
labelColor,
|
||||||
|
sides = SIDES,
|
||||||
|
showLabels = false,
|
||||||
|
}: SideHandlesProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sides.map((side) => {
|
||||||
|
const vertical = isVerticalSide(side)
|
||||||
|
return handlePositions(side, sideHandleCount(data, side)).map((pct, idx) => {
|
||||||
|
const sourceId = handleId(side, idx)
|
||||||
|
const targetId = `${sourceId}-t`
|
||||||
|
const offset: CSSProperties = vertical ? { top: `${pct}%` } : { left: `${pct}%` }
|
||||||
|
const labelStyle: CSSProperties = vertical
|
||||||
|
? { top: `${pct}%`, [side]: 3, transform: 'translateY(-50%)' }
|
||||||
|
: { left: `${pct}%`, [side]: 3, transform: 'translateX(-50%)' }
|
||||||
|
return (
|
||||||
|
<span key={sourceId}>
|
||||||
|
{showLabels && data.show_port_numbers && (
|
||||||
|
<span
|
||||||
|
className="absolute font-mono leading-none pointer-events-none select-none"
|
||||||
|
style={{ ...labelStyle, fontSize: 7, color: labelColor }}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Handle
|
||||||
|
type="source"
|
||||||
|
position={POSITION[side]}
|
||||||
|
id={sourceId}
|
||||||
|
style={{ ...offset, background: handleBackground, borderColor: handleBorder }}
|
||||||
|
/>
|
||||||
|
<Handle
|
||||||
|
type="target"
|
||||||
|
position={POSITION[side]}
|
||||||
|
id={targetId}
|
||||||
|
style={{ ...offset, opacity: 0, width: 20, height: 20 }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,9 @@ function renderNode(data: Partial<NodeData> = {}, selected = false) {
|
|||||||
type: 'proxmox',
|
type: 'proxmox',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
services: [],
|
services: [],
|
||||||
|
// Default tests to the container/group path (the branch this file covers);
|
||||||
|
// individual tests override with container_mode: false / unset as needed.
|
||||||
|
container_mode: true,
|
||||||
...data,
|
...data,
|
||||||
}
|
}
|
||||||
const props = {
|
const props = {
|
||||||
@@ -51,7 +54,7 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('renders the node label', () => {
|
it('renders the node label', () => {
|
||||||
const { getByText } = renderNode({ label: 'My Proxmox' })
|
const { getByText } = renderNode({ label: 'My Proxmox', container_mode: true })
|
||||||
expect(getByText('My Proxmox')).toBeDefined()
|
expect(getByText('My Proxmox')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,14 +93,21 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
expect(dot).not.toBeNull()
|
expect(dot).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
|
it('container_mode === false renders as BaseNode (no group border)', () => {
|
||||||
const { container } = renderNode({ container_mode: false })
|
const { container } = renderNode({ container_mode: false })
|
||||||
// NodeResizer should not be present when not group-rendered
|
// The group container uses rounded-xl border-2; BaseNode does not.
|
||||||
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
|
expect(container.querySelector('.rounded-xl.border-2')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('container_mode default renders the group border container', () => {
|
it('container mode is opt-in: default (unset) renders as a regular BaseNode', () => {
|
||||||
const { container } = renderNode({})
|
// Imported proxmox nodes leave container_mode unset and must look like a
|
||||||
|
// manually-created node (BaseNode), not an empty group container.
|
||||||
|
const { container } = renderNode({ container_mode: undefined })
|
||||||
|
expect(container.querySelector('.rounded-xl.border-2')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('container_mode === true renders the group border container', () => {
|
||||||
|
const { container } = renderNode({ container_mode: true })
|
||||||
// Group border div has rounded-xl border-2 classes
|
// Group border div has rounded-xl border-2 classes
|
||||||
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
|
||||||
})
|
})
|
||||||
@@ -114,10 +124,20 @@ describe('ProxmoxGroupNode', () => {
|
|||||||
expect(sourceHandles.length).toBe(1)
|
expect(sourceHandles.length).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders cluster handles in both modes', () => {
|
it('no longer renders the always-on cluster handles (#243)', () => {
|
||||||
const { container: groupC } = renderNode({})
|
const { container: groupC } = renderNode({})
|
||||||
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBe(0)
|
||||||
const { container: nodeC } = renderNode({ container_mode: false })
|
const { container: nodeC } = renderNode({ container_mode: false })
|
||||||
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
|
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders configurable left/right handles only when counts > 0', () => {
|
||||||
|
const { container: none } = renderNode({ container_mode: false })
|
||||||
|
expect(none.querySelectorAll('.react-flow__handle-left.source').length).toBe(0)
|
||||||
|
expect(none.querySelectorAll('.react-flow__handle-right.source').length).toBe(0)
|
||||||
|
|
||||||
|
const { container: set } = renderNode({ container_mode: false, left_handles: 1, right_handles: 2 })
|
||||||
|
expect(set.querySelectorAll('.react-flow__handle-left.source').length).toBe(1)
|
||||||
|
expect(set.querySelectorAll('.react-flow__handle-right.source').length).toBe(2)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import { SideHandles } from '../SideHandles'
|
||||||
|
import type { NodeData } from '@/types'
|
||||||
|
|
||||||
|
function renderHandles(data: Partial<NodeData> = {}) {
|
||||||
|
const full: NodeData = { label: 'n', type: 'server', status: 'online', services: [], ...data }
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<SideHandles
|
||||||
|
data={full}
|
||||||
|
handleBackground="#30363d"
|
||||||
|
handleBorder="#30363d"
|
||||||
|
labelColor="#8b949e"
|
||||||
|
/>
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SideHandles', () => {
|
||||||
|
it('renders a source + invisible target handle per slot', () => {
|
||||||
|
// default node: top=1, bottom=1, left=0, right=0
|
||||||
|
const { container } = renderHandles({})
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(2)
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.target').length).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('target (magnet) handle hit area is large enough to snap onto (20px)', () => {
|
||||||
|
const { container } = renderHandles({})
|
||||||
|
const target = container.querySelector('.react-flow__handle.target') as HTMLElement
|
||||||
|
expect(target.style.width).toBe('20px')
|
||||||
|
expect(target.style.height).toBe('20px')
|
||||||
|
expect(target.style.opacity).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders configured per-side counts', () => {
|
||||||
|
const { container } = renderHandles({ top_handles: 2, left_handles: 3, right_handles: 1, bottom_handles: 1 })
|
||||||
|
expect(container.querySelectorAll('.react-flow__handle.source').length).toBe(7)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,12 +10,18 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { clampHandles, sideDefault } from '@/utils/handleUtils'
|
||||||
import { THEMES } from '@/utils/themes'
|
import { THEMES } from '@/utils/themes'
|
||||||
import { applyOpacity } from '@/utils/colorUtils'
|
import { applyOpacity } from '@/utils/colorUtils'
|
||||||
import type {
|
import type {
|
||||||
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
|
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, EdgeLineStyle,
|
||||||
} from '@/types'
|
} from '@/types'
|
||||||
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
import { NODE_TYPE_LABELS, EDGE_TYPE_LABELS } from '@/types'
|
||||||
|
import {
|
||||||
|
EDGE_LINE_STYLES, EDGE_LINE_STYLE_LABELS, EDGE_TYPE_BASE_WIDTH, EDGE_TYPE_DEFAULT_LINE,
|
||||||
|
clampWidthMult, dashArrayFor,
|
||||||
|
} from '@/utils/edgeLineStyle'
|
||||||
|
import { MarkerShapePicker } from './MarkerShapePicker'
|
||||||
|
|
||||||
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
|
||||||
|
|
||||||
@@ -62,10 +68,41 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
|
|||||||
color: THEMES.default.colors.edgeColors[edgeType],
|
color: THEMES.default.colors.edgeColors[edgeType],
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
pathStyle: 'bezier',
|
pathStyle: 'bezier',
|
||||||
|
lineStyle: EDGE_TYPE_DEFAULT_LINE[edgeType],
|
||||||
|
widthMult: 1,
|
||||||
animated: 'none',
|
animated: 'none',
|
||||||
|
arrowStart: 'none',
|
||||||
|
arrowEnd: 'none',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edge line preview (renders the actual dash pattern + width) ────────────────
|
||||||
|
|
||||||
|
interface EdgeLineSwatchProps {
|
||||||
|
color: string
|
||||||
|
lineStyle: EdgeLineStyle
|
||||||
|
strokeWidth: number
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function EdgeLineSwatch({ color, lineStyle, strokeWidth, width = 40 }: EdgeLineSwatchProps) {
|
||||||
|
const h = 12
|
||||||
|
return (
|
||||||
|
<svg width={width} height={h} className="shrink-0" aria-hidden>
|
||||||
|
<line
|
||||||
|
x1={2}
|
||||||
|
y1={h / 2}
|
||||||
|
x2={width - 2}
|
||||||
|
y2={h / 2}
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={dashArrayFor(lineStyle, strokeWidth)}
|
||||||
|
strokeLinecap={lineStyle === 'dotted' ? 'round' : 'butt'}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Color + opacity row ──────────────────────────────────────────────────────
|
// ── Color + opacity row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ColorRowProps {
|
interface ColorRowProps {
|
||||||
@@ -178,6 +215,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[#30363d] pt-3">
|
||||||
|
<div className="text-xs text-[#8b949e] mb-1">Default connection points</div>
|
||||||
|
<div className="text-xs text-[#8b949e]/60 mb-2">New {NODE_TYPE_LABELS[nodeType]} nodes start with these (0–64 per side)</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{([
|
||||||
|
['Top', 'top', 'topHandles'],
|
||||||
|
['Right', 'right', 'rightHandles'],
|
||||||
|
['Bottom', 'bottom', 'bottomHandles'],
|
||||||
|
['Left', 'left', 'leftHandles'],
|
||||||
|
] as const).map(([label, side, key]) => (
|
||||||
|
<div key={side} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e] w-12">{label}</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={sideDefault(side)}
|
||||||
|
max={64}
|
||||||
|
step={1}
|
||||||
|
value={style[key] ?? sideDefault(side)}
|
||||||
|
onChange={(e) => set(key, clampHandles(side, parseInt(e.target.value, 10)))}
|
||||||
|
aria-label={`${label} default connection points`}
|
||||||
|
className="w-16 h-7 text-xs bg-[#0d1117] border border-[#30363d] rounded px-2 text-[#e6edf3]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
|
||||||
@@ -217,6 +281,52 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
|
<div className="border-t border-[#30363d] pt-3 flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">Line style</span>
|
||||||
|
<EdgeLineSwatch
|
||||||
|
color={applyOpacity(style.color, style.opacity)}
|
||||||
|
lineStyle={style.lineStyle}
|
||||||
|
strokeWidth={EDGE_TYPE_BASE_WIDTH[edgeType] * style.widthMult}
|
||||||
|
width={72}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{EDGE_LINE_STYLES.map((ls) => (
|
||||||
|
<button
|
||||||
|
key={ls}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('lineStyle', ls)}
|
||||||
|
className="px-3 py-1 text-xs rounded border transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: style.lineStyle === ls ? '#00d4ff' : '#30363d',
|
||||||
|
background: style.lineStyle === ls ? '#00d4ff22' : 'transparent',
|
||||||
|
color: style.lineStyle === ls ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EDGE_LINE_STYLE_LABELS[ls]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs text-[#8b949e]">Line width</span>
|
||||||
|
<span className="text-xs text-[#8b949e]">{style.widthMult}×</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
step={1}
|
||||||
|
value={style.widthMult}
|
||||||
|
onChange={(e) => set('widthMult', clampWidthMult(parseInt(e.target.value, 10)))}
|
||||||
|
aria-label="Line width multiplier"
|
||||||
|
className="w-full h-1 accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
|
<div className="text-xs text-[#8b949e] mb-2">Path style</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -251,6 +361,14 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
|
|||||||
<option value="snake">Snake</option>
|
<option value="snake">Snake</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-[#8b949e] mb-2">Endpoints</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<MarkerShapePicker label="Start" value={style.arrowStart} onChange={(s) => set('arrowStart', s)} />
|
||||||
|
<MarkerShapePicker label="End" value={style.arrowEnd} onChange={(s) => set('arrowEnd', s)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -272,9 +390,11 @@ type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeTy
|
|||||||
interface CustomStyleModalProps {
|
interface CustomStyleModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
/** When opening, preselect this node type's editor (shortcut from NodeModal). */
|
||||||
|
initialNodeType?: NodeType
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
export function CustomStyleModal({ open, onClose, initialNodeType }: CustomStyleModalProps) {
|
||||||
const { customStyle, setCustomStyle } = useThemeStore()
|
const { customStyle, setCustomStyle } = useThemeStore()
|
||||||
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
const { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
|
||||||
|
|
||||||
@@ -292,7 +412,12 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
setDraft({ nodes: { ...customStyle.nodes }, edges: { ...customStyle.edges } })
|
||||||
setSelection(null)
|
if (initialNodeType) {
|
||||||
|
setTab('nodes')
|
||||||
|
setSelection({ kind: 'node', type: initialNodeType })
|
||||||
|
} else {
|
||||||
|
setSelection(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
// Intentional snapshot-on-open: we don't want live customStyle changes to
|
||||||
// clobber an in-progress edit, only a fresh open should reset.
|
// clobber an in-progress edit, only a fresh open should reset.
|
||||||
@@ -417,6 +542,8 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
const swatchColor = style
|
const swatchColor = style
|
||||||
? applyOpacity(style.color, style.opacity)
|
? applyOpacity(style.color, style.opacity)
|
||||||
: THEMES.default.colors.edgeColors[t]
|
: THEMES.default.colors.edgeColors[t]
|
||||||
|
const lineStyle = style?.lineStyle ?? EDGE_TYPE_DEFAULT_LINE[t]
|
||||||
|
const widthMult = clampWidthMult(style?.widthMult)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -430,9 +557,10 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
|
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
|
||||||
<span
|
<EdgeLineSwatch
|
||||||
className="w-8 h-1.5 rounded-full shrink-0"
|
color={swatchColor}
|
||||||
style={{ background: swatchColor }}
|
lineStyle={lineStyle}
|
||||||
|
strokeWidth={EDGE_TYPE_BASE_WIDTH[t] * widthMult}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON, resolveDesignIcon } from '@/utils/designIcons'
|
||||||
|
import type { Design, FloorMapConfig } from '@/types'
|
||||||
|
|
||||||
export interface DesignFormData {
|
export interface DesignFormData {
|
||||||
name: string
|
name: string
|
||||||
icon: string
|
icon: string
|
||||||
|
/**
|
||||||
|
* Floor plan for THIS canvas. Only present when the floor-plan section is
|
||||||
|
* shown (edit mode on the active canvas). `null` means "remove the floor
|
||||||
|
* plan"; `undefined` means "leave it untouched".
|
||||||
|
*/
|
||||||
|
floorMap?: FloorMapConfig | null
|
||||||
|
/**
|
||||||
|
* When set, create the new canvas by deep-copying this existing design instead
|
||||||
|
* of starting blank. Only offered in create mode with `sourceDesigns` present.
|
||||||
|
*/
|
||||||
|
sourceId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DesignModalProps {
|
interface DesignModalProps {
|
||||||
@@ -17,21 +29,106 @@ interface DesignModalProps {
|
|||||||
initial?: DesignFormData
|
initial?: DesignFormData
|
||||||
title?: string
|
title?: string
|
||||||
submitLabel?: string
|
submitLabel?: string
|
||||||
|
/** Show the floor-plan section (only meaningful when editing the active canvas). */
|
||||||
|
showFloorMap?: boolean
|
||||||
|
/** Current floor plan of the canvas being edited (position preserved on save). */
|
||||||
|
initialFloorMap?: FloorMapConfig | null
|
||||||
|
/**
|
||||||
|
* Upload a selected image and resolve to its server URL. Required whenever
|
||||||
|
* the floor-plan section is shown — images are stored server-side, never as
|
||||||
|
* base64. Rejects on failure (caller surfaces the error).
|
||||||
|
*/
|
||||||
|
onUploadImage?: (file: File) => Promise<string>
|
||||||
|
/**
|
||||||
|
* Existing designs offered as a copy source (create mode only). When non-empty,
|
||||||
|
* a "Copy from existing" option appears; choosing it clones the picked canvas.
|
||||||
|
*/
|
||||||
|
sourceDesigns?: Design[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
|
export function DesignModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
initial,
|
||||||
|
title = 'New Canvas',
|
||||||
|
submitLabel = 'Create',
|
||||||
|
showFloorMap = false,
|
||||||
|
initialFloorMap = null,
|
||||||
|
onUploadImage,
|
||||||
|
sourceDesigns = [],
|
||||||
|
}: DesignModalProps) {
|
||||||
const [name, setName] = useState(initial?.name ?? '')
|
const [name, setName] = useState(initial?.name ?? '')
|
||||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||||
|
|
||||||
|
// "Copy from existing" is create-mode only (no floor-plan section shown).
|
||||||
|
const canCopy = !showFloorMap && sourceDesigns.length > 0
|
||||||
|
const [fromExisting, setFromExisting] = useState(false)
|
||||||
|
const [sourceId, setSourceId] = useState<string>(sourceDesigns[0]?.id ?? '')
|
||||||
|
|
||||||
|
// Floor plan state (only used when showFloorMap)
|
||||||
|
const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '')
|
||||||
|
const [width, setWidth] = useState(initialFloorMap?.width ?? 800)
|
||||||
|
const [height, setHeight] = useState(initialFloorMap?.height ?? 600)
|
||||||
|
const [opacity, setOpacity] = useState(initialFloorMap?.opacity ?? 0.8)
|
||||||
|
const [locked, setLocked] = useState(initialFloorMap?.locked ?? false)
|
||||||
|
const [enabled, setEnabled] = useState(initialFloorMap?.enabled ?? true)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
e.target.value = '' // allow re-selecting the same file after a failure
|
||||||
|
if (!file || !onUploadImage) return
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const url = await onUploadImage(file)
|
||||||
|
setImageData(url)
|
||||||
|
// Read natural dimensions from the served image (non-blocking).
|
||||||
|
const img = new Image()
|
||||||
|
img.onload = () => {
|
||||||
|
setWidth(img.naturalWidth)
|
||||||
|
setHeight(img.naturalHeight)
|
||||||
|
}
|
||||||
|
img.src = url
|
||||||
|
} catch {
|
||||||
|
// Caller surfaces the error toast; leave existing state untouched.
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
const trimmed = name.trim()
|
const trimmed = name.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
onSubmit({ name: trimmed, icon })
|
if (canCopy && fromExisting && !sourceId) return
|
||||||
|
const data: DesignFormData = { name: trimmed, icon }
|
||||||
|
if (canCopy && fromExisting && sourceId) {
|
||||||
|
data.sourceId = sourceId
|
||||||
|
}
|
||||||
|
if (showFloorMap) {
|
||||||
|
data.floorMap = imageData
|
||||||
|
? {
|
||||||
|
imageData,
|
||||||
|
// Preserve position from the existing config; new plans start at 0,0.
|
||||||
|
posX: initialFloorMap?.posX ?? 0,
|
||||||
|
posY: initialFloorMap?.posY ?? 0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
opacity,
|
||||||
|
locked,
|
||||||
|
enabled,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onSubmit(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasImage = !!imageData
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -75,6 +172,131 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{canCopy && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={!fromExisting}
|
||||||
|
onClick={() => setFromExisting(false)}
|
||||||
|
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
|
||||||
|
!fromExisting
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Blank canvas
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={fromExisting}
|
||||||
|
onClick={() => setFromExisting(true)}
|
||||||
|
className={`text-xs rounded-md border py-2 transition-colors cursor-pointer ${
|
||||||
|
fromExisting
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||||
|
: 'border-border text-muted-foreground hover:text-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Copy from existing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fromExisting && (
|
||||||
|
<div className="space-y-1 max-h-48 overflow-y-auto pr-1" role="radiogroup" aria-label="Source canvas">
|
||||||
|
{sourceDesigns.map((d) => {
|
||||||
|
const Icon = resolveDesignIcon(d.icon)
|
||||||
|
const selected = d.id === sourceId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={d.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={() => setSourceId(d.id)}
|
||||||
|
className={`w-full flex items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors cursor-pointer ${
|
||||||
|
selected
|
||||||
|
? 'border-[#00d4ff] bg-[#00d4ff]/10'
|
||||||
|
: 'border-border hover:border-[#30363d]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={16} className={selected ? 'text-[#00d4ff]' : 'text-muted-foreground'} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm">{d.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{d.node_count ?? 0} nodes · {d.group_count ?? 0} groups · {d.text_count ?? 0} text
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFloorMap && (
|
||||||
|
<div className="space-y-2 pt-2 border-t border-border">
|
||||||
|
<Label>Floor Plan</Label>
|
||||||
|
{!hasImage ? (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-6 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||||
|
<span className="text-muted-foreground text-sm">{uploading ? 'Uploading…' : 'Click to select a floor plan image'}</span>
|
||||||
|
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP · max 10 MB</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
|
||||||
|
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="secondary" className="cursor-pointer" disabled={uploading} onClick={() => fileRef.current?.click()}>
|
||||||
|
{uploading ? 'Uploading…' : 'Replace Image'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={() => setImageData('')}>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Width (px)</Label>
|
||||||
|
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Height (px)</Label>
|
||||||
|
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
|
||||||
|
<input
|
||||||
|
type="range" min="0.05" max="1" step="0.05" value={opacity}
|
||||||
|
onChange={(e) => setOpacity(Number(e.target.value))}
|
||||||
|
className="w-full accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs text-muted-foreground">Lock position & size</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs text-muted-foreground">Show on canvas</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|||||||
@@ -7,8 +7,14 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
import { EDGE_TYPE_LABELS, type EdgeData, type EdgeLineStyle, type EdgePathStyle, type EdgeType, type MarkerShape } from '@/types'
|
||||||
import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
import { EDGE_DEFAULT_COLORS } from '@/utils/edgeColors'
|
||||||
|
import { normalizeMarker } from '@/utils/edgeMarkers'
|
||||||
|
import {
|
||||||
|
EDGE_LINE_STYLES, EDGE_LINE_STYLE_LABELS, EDGE_TYPE_BASE_WIDTH, EDGE_TYPE_DEFAULT_LINE,
|
||||||
|
clampWidthMult, dashArrayFor,
|
||||||
|
} from '@/utils/edgeLineStyle'
|
||||||
|
import { MarkerShapePicker } from './MarkerShapePicker'
|
||||||
|
|
||||||
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
const EDGE_TYPES = Object.entries(EDGE_TYPE_LABELS) as [EdgeType, string][]
|
||||||
|
|
||||||
@@ -38,8 +44,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
|
||||||
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
|
||||||
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
const [animation, setAnimation] = useState<AnimMode>(() => toAnimMode(initial?.animated))
|
||||||
|
const [markerStart, setMarkerStart] = useState<MarkerShape>(normalizeMarker(initial?.marker_start))
|
||||||
|
const [markerEnd, setMarkerEnd] = useState<MarkerShape>(normalizeMarker(initial?.marker_end))
|
||||||
|
// Undefined = follow the edge type's default line preset (live, like color).
|
||||||
|
const [lineStyle, setLineStyle] = useState<EdgeLineStyle | undefined>(initial?.line_style)
|
||||||
|
const [widthMult, setWidthMult] = useState<number>(clampWidthMult(initial?.width_mult))
|
||||||
|
|
||||||
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
const effectiveColor = customColor ?? EDGE_DEFAULT_COLORS[type]
|
||||||
|
const effectiveLineStyle = lineStyle ?? EDGE_TYPE_DEFAULT_LINE[type]
|
||||||
|
const previewWidth = EDGE_TYPE_BASE_WIDTH[type] * widthMult
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -49,7 +62,11 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
|
line_style: effectiveLineStyle,
|
||||||
|
width_mult: widthMult,
|
||||||
animated: animation !== 'none' ? animation : undefined,
|
animated: animation !== 'none' ? animation : undefined,
|
||||||
|
marker_start: markerStart,
|
||||||
|
marker_end: markerEnd,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
@@ -130,6 +147,60 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Line Style</Label>
|
||||||
|
<svg width={56} height={12} aria-hidden>
|
||||||
|
<line
|
||||||
|
x1={2}
|
||||||
|
y1={6}
|
||||||
|
x2={54}
|
||||||
|
y2={6}
|
||||||
|
stroke={effectiveColor}
|
||||||
|
strokeWidth={previewWidth}
|
||||||
|
strokeDasharray={dashArrayFor(effectiveLineStyle, previewWidth)}
|
||||||
|
strokeLinecap={effectiveLineStyle === 'dotted' ? 'round' : 'butt'}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||||
|
{EDGE_LINE_STYLES.map((ls, i) => (
|
||||||
|
<button
|
||||||
|
key={ls}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLineStyle(ls)}
|
||||||
|
className="flex-1 py-1 text-xs transition-colors cursor-pointer"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Line style ${ls}`}
|
||||||
|
style={{
|
||||||
|
background: effectiveLineStyle === ls ? '#00d4ff22' : '#21262d',
|
||||||
|
color: effectiveLineStyle === ls ? '#00d4ff' : '#8b949e',
|
||||||
|
borderRight: i < EDGE_LINE_STYLES.length - 1 ? '1px solid #30363d' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EDGE_LINE_STYLE_LABELS[ls]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs text-muted-foreground">Line Width</Label>
|
||||||
|
<span className="text-xs text-muted-foreground">{widthMult}×</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={4}
|
||||||
|
step={1}
|
||||||
|
value={widthMult}
|
||||||
|
onChange={(e) => setWidthMult(clampWidthMult(parseInt(e.target.value, 10)))}
|
||||||
|
aria-label="Line width multiplier"
|
||||||
|
className="w-full h-1 accent-[#00d4ff]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Animation</Label>
|
<Label className="text-xs text-muted-foreground">Animation</Label>
|
||||||
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
<div className={`flex rounded-md overflow-hidden border border-[#30363d] ${modalStyles['modal-interactive']}`}>
|
||||||
@@ -153,6 +224,14 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Endpoints</Label>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<MarkerShapePicker label="Start" value={markerStart} onChange={setMarkerStart} />
|
||||||
|
<MarkerShapePicker label="End" value={markerEnd} onChange={setMarkerEnd} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs text-muted-foreground">Color</Label>
|
<Label className="text-xs text-muted-foreground">Color</Label>
|
||||||
|
|||||||
@@ -120,12 +120,17 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
{/* ── LEFT column: content & text ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Content</div>
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Label</Label>
|
<Label className="text-xs text-muted-foreground">Label</Label>
|
||||||
@@ -209,6 +214,38 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Text size */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text Size</Label>
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{TEXT_SIZES.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_size === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('text_size', value)}
|
||||||
|
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
||||||
|
aria-label={`Text size ${label}`}
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
fontSize: value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>{/* ── end LEFT column ── */}
|
||||||
|
|
||||||
|
{/* ── RIGHT column: style ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Style</div>
|
||||||
|
|
||||||
{/* Colors */}
|
{/* Colors */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
@@ -245,33 +282,6 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Text size */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs text-muted-foreground">Text Size</Label>
|
|
||||||
<div className="grid grid-cols-6 gap-1">
|
|
||||||
{TEXT_SIZES.map(({ value, label }) => {
|
|
||||||
const isSelected = form.text_size === value
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => set('text_size', value)}
|
|
||||||
className={`flex items-center justify-center h-8 rounded transition-colors cursor-pointer ${modalStyles['modal-interactive']}`}
|
|
||||||
aria-label={`Text size ${label}`}
|
|
||||||
style={{
|
|
||||||
background: isSelected ? '#00d4ff22' : '#21262d',
|
|
||||||
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
|
||||||
color: isSelected ? '#00d4ff' : '#8b949e',
|
|
||||||
fontSize: value,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Border style */}
|
{/* Border style */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
@@ -342,6 +352,8 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* ── end RIGHT column ── */}
|
||||||
|
</div>{/* ── end 2-column grid ── */}
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
{onDelete && (
|
{onDelete && (
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { ReactElement } from 'react'
|
||||||
|
import type { MarkerShape } from '@/types'
|
||||||
|
import { MARKER_SHAPES } from '@/utils/edgeMarkers'
|
||||||
|
|
||||||
|
/** 16x16 preview glyph for a marker shape (used in the picker buttons). */
|
||||||
|
function markerGlyph(shape: MarkerShape, color: string): ReactElement {
|
||||||
|
switch (shape) {
|
||||||
|
case 'none':
|
||||||
|
return <line x1={3} y1={8} x2={13} y2={8} stroke={color} strokeWidth={1.5} strokeLinecap="round" />
|
||||||
|
case 'arrow':
|
||||||
|
return <path d="M4 4 L12 8 L4 12 z" fill={color} />
|
||||||
|
case 'arrow-open':
|
||||||
|
return <path d="M5 4 L11 8 L5 12" fill="none" stroke={color} strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
case 'circle':
|
||||||
|
return <circle cx={8} cy={8} r={4} fill={color} />
|
||||||
|
case 'diamond':
|
||||||
|
return <path d="M8 3 L13 8 L8 13 L3 8 z" fill={color} />
|
||||||
|
case 'square':
|
||||||
|
return <rect x={4} y={4} width={8} height={8} fill={color} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkerShapePickerProps {
|
||||||
|
label: string
|
||||||
|
value: MarkerShape
|
||||||
|
onChange: (shape: MarkerShape) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A labeled row of buttons to pick the marker shape for one edge end. */
|
||||||
|
export function MarkerShapePicker({ label, value, onChange }: MarkerShapePickerProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-[#8b949e] w-10 shrink-0">{label}</span>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{MARKER_SHAPES.map((shape) => {
|
||||||
|
const active = value === shape
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={shape}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(shape)}
|
||||||
|
aria-label={`${label} marker ${shape}`}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={shape}
|
||||||
|
className="w-7 h-7 rounded border flex items-center justify-center transition-colors shrink-0"
|
||||||
|
style={{
|
||||||
|
borderColor: active ? '#00d4ff' : '#30363d',
|
||||||
|
background: active ? '#00d4ff22' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width={16} height={16} viewBox="0 0 16 16">
|
||||||
|
{markerGlyph(shape, active ? '#00d4ff' : '#8b949e')}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { Fragment, createElement, useState } from 'react'
|
import { Fragment, createElement, useState } from 'react'
|
||||||
import modalStyles from './modal-interactive.module.css'
|
import modalStyles from './modal-interactive.module.css'
|
||||||
import { RotateCcw, ChevronDown } from 'lucide-react'
|
import { RotateCcw, ChevronDown, Palette } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
|
import { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod, type NodeTypeStyle } from '@/types'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { resolveNodeColors } from '@/utils/nodeColors'
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
import { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
|
||||||
import { BrandIconPicker } from './BrandIconPicker'
|
import { BrandIconPicker } from './BrandIconPicker'
|
||||||
import { MIN_BOTTOM_HANDLES, MAX_BOTTOM_HANDLES, clampBottomHandles } from '@/utils/handleUtils'
|
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
|
||||||
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
|
||||||
|
|
||||||
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
||||||
@@ -24,6 +26,65 @@ const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
|
|||||||
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
{ label: 'Generic', types: ['generic', 'groupRect'] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Maps a side to its per-type default field on NodeTypeStyle.
|
||||||
|
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
|
||||||
|
top: 'topHandles',
|
||||||
|
bottom: 'bottomHandles',
|
||||||
|
left: 'leftHandles',
|
||||||
|
right: 'rightHandles',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact per-side connection-point control: [− N +] with a typable value.
|
||||||
|
* Placed spatially around a node preview (see the Connection Points section).
|
||||||
|
*/
|
||||||
|
function CPStepper({ label, side, value, onChange }: {
|
||||||
|
label: string
|
||||||
|
side: Side
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
}) {
|
||||||
|
const min = sideDefault(side)
|
||||||
|
const labelEl = <span className="text-[10px] text-muted-foreground/80 leading-none">{label}</span>
|
||||||
|
const belowLabel = side === 'bottom'
|
||||||
|
const btn = 'w-6 h-full flex items-center justify-center text-sm text-muted-foreground hover:text-foreground hover:bg-[#21262d] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-default'
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1">
|
||||||
|
{!belowLabel && labelEl}
|
||||||
|
<div className="flex items-center h-7 rounded-md border border-[#30363d] bg-[#0d1117] overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Decrease ${label} connection points`}
|
||||||
|
onClick={() => onChange(clampHandles(side, value - 1))}
|
||||||
|
disabled={value <= min}
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={MAX_HANDLES}
|
||||||
|
value={value}
|
||||||
|
aria-label={`${label} connection points`}
|
||||||
|
onChange={(e) => onChange(clampHandles(side, Number(e.target.value)))}
|
||||||
|
className="w-9 h-full bg-transparent text-center text-xs font-mono text-foreground outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Increase ${label} connection points`}
|
||||||
|
onClick={() => onChange(clampHandles(side, value + 1))}
|
||||||
|
disabled={value >= MAX_HANDLES}
|
||||||
|
className={btn}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{belowLabel && labelEl}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
|
||||||
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
const CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
|
||||||
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
|
||||||
@@ -71,11 +132,13 @@ interface NodeModalProps {
|
|||||||
title?: string
|
title?: string
|
||||||
parentCandidates?: ParentCandidate[]
|
parentCandidates?: ParentCandidate[]
|
||||||
currentNodeId?: string
|
currentNodeId?: string
|
||||||
|
/** Shortcut: open the Custom Style editor for this node's type (canvas-wide). */
|
||||||
|
onEditTypeStyle?: (type: NodeType) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
// NodeModal is always mounted with a key that changes on open/edit, so useState
|
||||||
// initial value is enough - no need for a reset effect.
|
// initial value is enough - no need for a reset effect.
|
||||||
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId }: NodeModalProps) {
|
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', parentCandidates = [], currentNodeId, onEditTypeStyle }: NodeModalProps) {
|
||||||
const merged = { ...DEFAULT_DATA, ...initial }
|
const merged = { ...DEFAULT_DATA, ...initial }
|
||||||
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
|
||||||
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
const [form, setForm] = useState<Partial<NodeData>>(merged)
|
||||||
@@ -94,6 +157,16 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const set = (key: keyof NodeData, value: unknown) =>
|
const set = (key: keyof NodeData, value: unknown) =>
|
||||||
setForm((f) => ({ ...f, [key]: value }))
|
setForm((f) => ({ ...f, [key]: value }))
|
||||||
|
|
||||||
|
const customStyle = useThemeStore((s) => s.customStyle)
|
||||||
|
// Effective default count for a side: the per-type style default if set,
|
||||||
|
// otherwise the intrinsic side default (top/bottom → 1, left/right → 0).
|
||||||
|
const effectiveSideDefault = (side: Side): number => {
|
||||||
|
const styleVal = customStyle.nodes[(form.type ?? 'generic') as NodeType]?.[SIDE_STYLE_KEY[side]]
|
||||||
|
return clampHandles(side, typeof styleVal === 'number' ? styleVal : sideDefault(side))
|
||||||
|
}
|
||||||
|
const sideValue = (side: Side): number =>
|
||||||
|
clampHandles(side, form[handleCountField(side)] ?? effectiveSideDefault(side))
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.label?.trim()) {
|
if (!form.label?.trim()) {
|
||||||
@@ -113,8 +186,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
const parent = parentCandidates.find((n) => n.id === safeParentId)
|
||||||
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
if (!parent || !isValidParent(parent)) safeParentId = undefined
|
||||||
}
|
}
|
||||||
|
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
|
||||||
onSubmit({
|
onSubmit({
|
||||||
...form,
|
...form,
|
||||||
|
// Persist the resolved per-side counts so type-style defaults (and
|
||||||
|
// untouched sliders) are baked into the node. Skipped for group types.
|
||||||
|
...(isGroupType ? {} : {
|
||||||
|
top_handles: sideValue('top'),
|
||||||
|
bottom_handles: sideValue('bottom'),
|
||||||
|
left_handles: sideValue('left'),
|
||||||
|
right_handles: sideValue('right'),
|
||||||
|
}),
|
||||||
parent_id: safeParentId,
|
parent_id: safeParentId,
|
||||||
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
container_mode: canUseContainerMode ? !!form.container_mode : false,
|
||||||
})
|
})
|
||||||
@@ -123,13 +205,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md max-h-[90vh] overflow-y-auto">
|
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||||
|
{/* ── LEFT column: identity & network ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Information</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Type + Icon on the same row */}
|
{/* Type + Icon on the same row */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Type</Label>
|
<Label className="text-xs text-muted-foreground">Type</Label>
|
||||||
@@ -208,6 +294,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* end Type/Icon subgrid */}
|
||||||
|
|
||||||
{/* Inline icon picker - full width, shown below the type+icon row */}
|
{/* Inline icon picker - full width, shown below the type+icon row */}
|
||||||
{iconPickerOpen && (
|
{iconPickerOpen && (
|
||||||
@@ -305,6 +392,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Hostname</Label>
|
<Label className="text-xs text-muted-foreground">Hostname</Label>
|
||||||
@@ -327,7 +415,9 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
<span className="text-[10px] text-muted-foreground/50">comma-separated</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>{/* end Hostname/IP subgrid */}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
{/* Check method — hidden for zigbee nodes (always none/online) */}
|
||||||
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -357,6 +447,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>{/* end Check method/target subgrid */}
|
||||||
|
|
||||||
{/* Parent Container */}
|
{/* Parent Container */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -423,7 +514,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.notes ?? ''}
|
||||||
|
onChange={(e) => set('notes', e.target.value)}
|
||||||
|
placeholder="Optional notes"
|
||||||
|
rows={3}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm resize-y min-h-16 ${modalStyles['modal-radius']}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>{/* ── end LEFT column ── */}
|
||||||
|
|
||||||
|
{/* ── RIGHT column: display ── */}
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pb-1 border-b border-[#30363d]">Design</div>
|
||||||
{/* Service visibility */}
|
{/* Service visibility */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex items-start justify-between col-span-2 py-1">
|
<div className="flex items-start justify-between col-span-2 py-1">
|
||||||
@@ -507,33 +613,53 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onEditTypeStyle && form.type !== 'group' && form.type !== 'groupRect' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onEditTypeStyle((form.type ?? 'generic') as NodeType)}
|
||||||
|
className="flex items-center gap-1 self-start text-[10px] text-[#00d4ff] hover:underline"
|
||||||
|
>
|
||||||
|
<Palette size={10} /> Edit {NODE_TYPE_LABELS[form.type ?? 'generic']} style for all nodes on the canvas
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom connection points (not for group containers) */}
|
{/* Connection points per side (not for group containers) */}
|
||||||
{form.type !== 'groupRect' && form.type !== 'group' && (
|
{form.type !== 'groupRect' && form.type !== 'group' && (
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
<div className="flex flex-col gap-2.5 col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<Label className="text-xs text-muted-foreground">Connection Points</Label>
|
||||||
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
|
{/* Spatial cross: each side's stepper sits where that side is. */}
|
||||||
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
|
||||||
</div>
|
<div />
|
||||||
<input
|
<CPStepper label="Top" side="top" value={sideValue('top')}
|
||||||
type="range"
|
onChange={(v) => set('top_handles', v)} />
|
||||||
min={MIN_BOTTOM_HANDLES}
|
<div />
|
||||||
max={MAX_BOTTOM_HANDLES}
|
|
||||||
step={1}
|
<CPStepper label="Left" side="left" value={sideValue('left')}
|
||||||
value={clampBottomHandles(form.bottom_handles ?? 1)}
|
onChange={(v) => set('left_handles', v)} />
|
||||||
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
|
<div
|
||||||
aria-label="Bottom connection points slider"
|
className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
|
||||||
className="w-full accent-[#00d4ff] cursor-pointer"
|
style={{
|
||||||
/>
|
width: 64, height: 40,
|
||||||
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
|
borderColor: resolvedNodeColors.border,
|
||||||
<span>{MIN_BOTTOM_HANDLES}</span>
|
background: `${resolvedNodeColors.background}`,
|
||||||
<span>{MAX_BOTTOM_HANDLES}</span>
|
color: resolvedNodeColors.icon,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
node
|
||||||
|
</div>
|
||||||
|
<CPStepper label="Right" side="right" value={sideValue('right')}
|
||||||
|
onChange={(v) => set('right_handles', v)} />
|
||||||
|
|
||||||
|
<div />
|
||||||
|
<CPStepper label="Bottom" side="bottom" value={sideValue('bottom')}
|
||||||
|
onChange={(v) => set('bottom_handles', v)} />
|
||||||
|
<div />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between pt-1">
|
<div className="flex items-center justify-between pt-1">
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
<Label className="text-xs text-muted-foreground">Show Port Numbers</Label>
|
||||||
<span className="text-[10px] text-muted-foreground/60">Label each bottom connection point</span>
|
<span className="text-[10px] text-muted-foreground/60">Label each connection point</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -554,16 +680,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
</div>{/* ── end RIGHT column ── */}
|
||||||
<div className="flex flex-col gap-1.5 col-span-2">
|
|
||||||
<Label className="text-xs text-muted-foreground">Notes</Label>
|
|
||||||
<Input
|
|
||||||
value={form.notes ?? ''}
|
|
||||||
onChange={(e) => set('notes', e.target.value)}
|
|
||||||
placeholder="Optional notes"
|
|
||||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-2 pt-1">
|
<div className="flex justify-between gap-2 pt-1">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
|
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import type { NodeProperty } from '@/types'
|
||||||
|
|
||||||
interface Service {
|
interface Service {
|
||||||
port: number
|
port: number
|
||||||
@@ -20,12 +21,18 @@ export interface PendingDevice {
|
|||||||
suggested_type: string | null
|
suggested_type: string | null
|
||||||
status: string
|
status: string
|
||||||
discovery_source: string | null
|
discovery_source: string | null
|
||||||
|
// All sources that have observed this device (e.g. ["arp", "proxmox"]). A
|
||||||
|
// merged device shows under every matching filter. Falls back to
|
||||||
|
// [discovery_source] when absent (older rows).
|
||||||
|
discovery_sources?: string[]
|
||||||
ieee_address?: string | null
|
ieee_address?: string | null
|
||||||
friendly_name?: string | null
|
friendly_name?: string | null
|
||||||
device_subtype?: string | null
|
device_subtype?: string | null
|
||||||
model?: string | null
|
model?: string | null
|
||||||
vendor?: string | null
|
vendor?: string | null
|
||||||
lqi?: number | null
|
lqi?: number | null
|
||||||
|
// Display properties carried from discovery (e.g. Proxmox specs).
|
||||||
|
properties?: NodeProperty[]
|
||||||
discovered_at: string
|
discovered_at: string
|
||||||
// How many canvases (designs) this device already appears on. Computed server-side.
|
// How many canvases (designs) this device already appears on. Computed server-side.
|
||||||
canvas_count?: number
|
canvas_count?: number
|
||||||
|
|||||||
@@ -4,17 +4,21 @@ import {
|
|||||||
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
|
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi, type DuplicateNodeConflict } from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import { resolveNodeColors } from '@/utils/nodeColors'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
import type { NodeType, ServiceInfo } from '@/types'
|
import type { NodeType, ServiceInfo } from '@/types'
|
||||||
|
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
|
||||||
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
|
||||||
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
|
||||||
import { buildMacProperty } from '@/utils/macProperty'
|
import { buildMacProperty } from '@/utils/macProperty'
|
||||||
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
|
||||||
import { getCenteredPosition } from '@/utils/viewportCenter'
|
import { getCenteredPosition } from '@/utils/viewportCenter'
|
||||||
|
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
|
||||||
|
|
||||||
interface PendingDevicesModalProps {
|
interface PendingDevicesModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -71,16 +75,9 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
|
|||||||
generic: Circle,
|
generic: Circle,
|
||||||
}
|
}
|
||||||
|
|
||||||
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
type SourceFilter = 'all' | SourceBucket
|
||||||
type StatusFilter = 'pending' | 'hidden'
|
type StatusFilter = 'pending' | 'hidden'
|
||||||
|
|
||||||
function inferSource(d: PendingDevice): 'zigbee' | 'zwave' | 'ip' {
|
|
||||||
if (d.discovery_source === 'zwave') return 'zwave'
|
|
||||||
if (d.discovery_source === 'zigbee') return 'zigbee'
|
|
||||||
if (d.ieee_address) return 'zigbee'
|
|
||||||
return 'ip'
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMON_PORTS = new Set([22, 80, 443])
|
const COMMON_PORTS = new Set([22, 80, 443])
|
||||||
|
|
||||||
function specialServiceName(d: PendingDevice): string | undefined {
|
function specialServiceName(d: PendingDevice): string | undefined {
|
||||||
@@ -96,21 +93,22 @@ function deviceLabel(d: PendingDevice): string {
|
|||||||
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectAutoEdges(edges: { id: string; source: string; target: string }[] | undefined) {
|
// Pull the duplicate-conflict payload out of a 409 approve response, if that's
|
||||||
|
// what the error is. Anything else (network, 500, non-duplicate 409) → null.
|
||||||
|
function extractDuplicateConflict(err: unknown): DuplicateNodeConflict | null {
|
||||||
|
const detail = (err as { response?: { status?: number; data?: { detail?: unknown } } })?.response
|
||||||
|
if (detail?.status !== 409) return null
|
||||||
|
const body = detail.data?.detail
|
||||||
|
if (body && typeof body === 'object' && (body as DuplicateNodeConflict).duplicate) {
|
||||||
|
return body as DuplicateNodeConflict
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectAutoEdges(edges: AutoEdge[] | undefined) {
|
||||||
if (!edges || edges.length === 0) return
|
if (!edges || edges.length === 0) return
|
||||||
useCanvasStore.setState((state) => ({
|
useCanvasStore.setState((state) => ({
|
||||||
edges: [
|
...applyAutoEdges(state.nodes, state.edges, edges),
|
||||||
...state.edges,
|
|
||||||
...edges.map((e) => ({
|
|
||||||
id: e.id,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
sourceHandle: 'bottom',
|
|
||||||
targetHandle: 'top-t',
|
|
||||||
type: 'iot',
|
|
||||||
data: { type: 'iot' as const },
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
hasUnsavedChanges: true,
|
hasUnsavedChanges: true,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -130,8 +128,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
// Optionally restrict to devices that have at least one detected service.
|
// Optionally restrict to devices that have at least one detected service.
|
||||||
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
const [withServicesOnly, setWithServicesOnly] = useState(false)
|
||||||
const { addNode, scanEventTs } = useCanvasStore()
|
const { addNode, scanEventTs } = useCanvasStore()
|
||||||
|
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
|
||||||
const activeDesignId = useDesignStore((s) => s.activeDesignId)
|
const activeDesignId = useDesignStore((s) => s.activeDesignId)
|
||||||
const highlightRef = useRef<HTMLButtonElement>(null)
|
const highlightRef = useRef<HTMLButtonElement>(null)
|
||||||
|
// Set when a single approve is refused because the same host is already on
|
||||||
|
// this design — the user decides: link to the existing node or duplicate.
|
||||||
|
const [dupPrompt, setDupPrompt] = useState<{ device: PendingDevice; conflict: DuplicateNodeConflict } | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -168,7 +170,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase()
|
const q = search.trim().toLowerCase()
|
||||||
return devices.filter((d) => {
|
return devices.filter((d) => {
|
||||||
if (sourceFilter !== 'all' && inferSource(d) !== sourceFilter) return false
|
if (sourceFilter !== 'all' && !sourceBuckets(d).has(sourceFilter)) return false
|
||||||
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
|
||||||
// Inventory-only: optionally hide devices already placed on a canvas.
|
// Inventory-only: optionally hide devices already placed on a canvas.
|
||||||
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
|
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
|
||||||
@@ -267,30 +269,32 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApprove = async (device: PendingDevice) => {
|
// force=true is sent only after the user confirms they want a duplicate node
|
||||||
|
// on this design (the backend otherwise 409s to let us ask).
|
||||||
|
const approveDevice = async (device: PendingDevice, force = false) => {
|
||||||
|
const fallbackLabel = deviceLabel(device)
|
||||||
|
const type = (device.suggested_type ?? 'generic') as NodeType
|
||||||
|
const zwave = isZwaveType(type)
|
||||||
|
const wireless = isZigbeeType(type) || zwave
|
||||||
|
const properties = zwave
|
||||||
|
? buildZwaveProperties(device)
|
||||||
|
: isZigbeeType(type)
|
||||||
|
? buildZigbeeProperties(device)
|
||||||
|
: [...(device.properties ?? []), ...buildMacProperty(device.mac)]
|
||||||
|
const nodeData = {
|
||||||
|
label: fallbackLabel,
|
||||||
|
type,
|
||||||
|
ip: device.ip ?? undefined,
|
||||||
|
mac: device.mac ?? undefined,
|
||||||
|
hostname: device.hostname ?? undefined,
|
||||||
|
status: wireless ? 'online' : 'unknown',
|
||||||
|
services: (device.services ?? []) as ServiceInfo[],
|
||||||
|
properties,
|
||||||
|
// Approve onto the design the user is viewing, not the first design.
|
||||||
|
design_id: activeDesignId ?? undefined,
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const fallbackLabel = deviceLabel(device)
|
const res = await scanApi.approve(device.id, { ...nodeData, force })
|
||||||
const type = (device.suggested_type ?? 'generic') as NodeType
|
|
||||||
const zwave = isZwaveType(type)
|
|
||||||
const wireless = isZigbeeType(type) || zwave
|
|
||||||
const properties = zwave
|
|
||||||
? buildZwaveProperties(device)
|
|
||||||
: isZigbeeType(type)
|
|
||||||
? buildZigbeeProperties(device)
|
|
||||||
: buildMacProperty(device.mac)
|
|
||||||
const nodeData = {
|
|
||||||
label: fallbackLabel,
|
|
||||||
type,
|
|
||||||
ip: device.ip ?? undefined,
|
|
||||||
mac: device.mac ?? undefined,
|
|
||||||
hostname: device.hostname ?? undefined,
|
|
||||||
status: wireless ? 'online' : 'unknown',
|
|
||||||
services: (device.services ?? []) as ServiceInfo[],
|
|
||||||
properties,
|
|
||||||
// Approve onto the design the user is viewing, not the first design.
|
|
||||||
design_id: activeDesignId ?? undefined,
|
|
||||||
}
|
|
||||||
const res = await scanApi.approve(device.id, nodeData)
|
|
||||||
const nodeId = res.data.node_id
|
const nodeId = res.data.node_id
|
||||||
addNode({
|
addNode({
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
@@ -301,13 +305,35 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
injectAutoEdges(res.data.edges)
|
injectAutoEdges(res.data.edges)
|
||||||
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
toast.success(`Approved ${nodeData.label}${extra}`)
|
toast.success(`Approved ${nodeData.label}${extra}`)
|
||||||
setDevices((prev) => prev.filter((d) => d.id !== device.id))
|
// Keep the row (now on-canvas, shown with an "In N canvas" badge); reload
|
||||||
|
// for a fresh canvas_count rather than dropping it until reopen.
|
||||||
setSelected(null)
|
setSelected(null)
|
||||||
} catch {
|
setDupPrompt(null)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
const conflict = extractDuplicateConflict(err)
|
||||||
|
// Only ask on the first (non-forced) attempt; a forced retry that still
|
||||||
|
// fails is a real error.
|
||||||
|
if (conflict && !force) {
|
||||||
|
// Close the device-detail modal first: two Base UI dialogs open at once
|
||||||
|
// trap focus on the underlying one and the prompt never shows.
|
||||||
|
setSelected(null)
|
||||||
|
setDupPrompt({ device, conflict })
|
||||||
|
return
|
||||||
|
}
|
||||||
toast.error('Failed to approve device')
|
toast.error('Failed to approve device')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
|
||||||
|
|
||||||
|
const goToExistingNode = (nodeId: string) => {
|
||||||
|
setSelectedNode(nodeId)
|
||||||
|
setDupPrompt(null)
|
||||||
|
setSelected(null)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
const handleHide = async (device: PendingDevice) => {
|
const handleHide = async (device: PendingDevice) => {
|
||||||
try {
|
try {
|
||||||
await scanApi.hide(device.id)
|
await scanApi.hide(device.id)
|
||||||
@@ -362,15 +388,29 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
? buildZwaveProperties(d)
|
? buildZwaveProperties(d)
|
||||||
: isZigbeeType(type)
|
: isZigbeeType(type)
|
||||||
? buildZigbeeProperties(d)
|
? buildZigbeeProperties(d)
|
||||||
: buildMacProperty(d.mac),
|
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
injectAutoEdges(res.data.edges)
|
injectAutoEdges(res.data.edges)
|
||||||
setDevices((prev) => prev.filter((d) => !ids.includes(d.id)))
|
// Don't strip approved rows locally: the inventory keeps them with an
|
||||||
|
// "In N canvas" badge (that's what showOnCanvas toggles). Reload so they
|
||||||
|
// reappear with a fresh canvas_count instead of vanishing until reopen.
|
||||||
setSelectedIds(new Set())
|
setSelectedIds(new Set())
|
||||||
|
await load()
|
||||||
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
|
||||||
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
|
||||||
|
// Bulk can't prompt per-device, so report the ones already on this canvas
|
||||||
|
// (skipped as duplicates) instead of silently dropping them.
|
||||||
|
const dupes = res.data.skipped_devices ?? []
|
||||||
|
if (dupes.length > 0) {
|
||||||
|
const names = dupes.slice(0, 3).map((d) => d.label).join(', ')
|
||||||
|
const more = dupes.length > 3 ? ` +${dupes.length - 3} more` : ''
|
||||||
|
toast.info(
|
||||||
|
`${dupes.length} already on this canvas, skipped: ${names}${more}`,
|
||||||
|
{ description: 'Matched an existing node by IP/MAC/IEEE on this design.' },
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to bulk approve devices')
|
toast.error('Failed to bulk approve devices')
|
||||||
}
|
}
|
||||||
@@ -501,6 +541,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
>
|
>
|
||||||
Z-Wave
|
Z-Wave
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSourceFilter('proxmox')}
|
||||||
|
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'proxmox' ? 'bg-[#e57000]/20 text-[#e57000]' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
|
||||||
|
>
|
||||||
|
Proxmox
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
@@ -632,6 +678,59 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Duplicate confirmation: the host is already on this design. Ask
|
||||||
|
before creating a second card — never silently drop or duplicate.
|
||||||
|
Rendered INSIDE the inventory DialogContent on purpose: an open
|
||||||
|
Base UI Dialog marks all outside content inert/aria-hidden, so a
|
||||||
|
sibling overlay (or nested dialog) would be unclickable. Keeping it
|
||||||
|
in the dialog's own subtree avoids that. */}
|
||||||
|
{dupPrompt && (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Device already on this canvas"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
onClick={() => setDupPrompt(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-lg border border-border bg-[#161b22] p-5 shadow-xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-foreground mb-3">Device already on this canvas</h2>
|
||||||
|
<div className="text-sm text-muted-foreground space-y-3">
|
||||||
|
<p>
|
||||||
|
<span className="text-foreground font-medium break-all">{deviceLabel(dupPrompt.device)}</span>{' '}
|
||||||
|
matches a node already on this design
|
||||||
|
{' '}(<span className="uppercase text-[11px] font-mono">{dupPrompt.conflict.match}</span>{' '}
|
||||||
|
<span className="font-mono text-foreground">{dupPrompt.conflict.value}</span>):{' '}
|
||||||
|
<span className="text-foreground font-medium break-all">{dupPrompt.conflict.existing_label}</span>.
|
||||||
|
</p>
|
||||||
|
<p>Add it again anyway, or jump to the existing node?</p>
|
||||||
|
<div className="flex flex-wrap justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setDupPrompt(null)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded border border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => goToExistingNode(dupPrompt.conflict.existing_node_id)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#00d4ff]/20 text-[#00d4ff] hover:bg-[#00d4ff]/30 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Go to existing node
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => approveDevice(dupPrompt.device, true)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded bg-[#e3b341]/20 text-[#e3b341] hover:bg-[#e3b341]/30 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Add duplicate anyway
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
@@ -656,14 +755,14 @@ interface DeviceCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
|
||||||
const source = inferSource(device)
|
const sources = orderedSources(device)
|
||||||
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
|
const roleType = (device.suggested_type ?? 'generic') as NodeType
|
||||||
|
const Icon = TYPE_ICONS[roleType] ?? Circle
|
||||||
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
// Colour the role badge with the same accent the node uses on the canvas
|
||||||
|
// (from the active theme / style section), instead of a flat grey.
|
||||||
|
const roleColor = resolveNodeColors({ type: roleType, custom_colors: undefined }, activeTheme).border
|
||||||
const label = deviceLabel(device)
|
const label = deviceLabel(device)
|
||||||
const sourceColor = source === 'zigbee' ? '#00d4ff' : source === 'zwave' ? '#ff6e00' : '#a855f7'
|
|
||||||
const sourceLabel =
|
|
||||||
source === 'zigbee' ? 'ZIGBEE'
|
|
||||||
: source === 'zwave' ? 'Z-WAVE'
|
|
||||||
: (device.discovery_source ?? 'IP').toUpperCase()
|
|
||||||
const services = device.services ?? []
|
const services = device.services ?? []
|
||||||
const visibleServices = services.slice(0, 4)
|
const visibleServices = services.slice(0, 4)
|
||||||
const moreServices = services.length - visibleServices.length
|
const moreServices = services.length - visibleServices.length
|
||||||
@@ -727,14 +826,20 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
<div className="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
|
||||||
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||||
<span
|
{sources.map((s) => (
|
||||||
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
<span
|
||||||
style={{ background: `${sourceColor}22`, color: sourceColor }}
|
key={s}
|
||||||
>
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
{sourceLabel}
|
style={{ background: `${SOURCE_META[s].color}22`, color: SOURCE_META[s].color }}
|
||||||
</span>
|
>
|
||||||
|
{SOURCE_META[s].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
{device.suggested_type && (
|
{device.suggested_type && (
|
||||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#21262d] text-muted-foreground">
|
<span
|
||||||
|
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
|
||||||
|
style={{ background: `${roleColor}22`, color: roleColor }}
|
||||||
|
>
|
||||||
{device.suggested_type}
|
{device.suggested_type}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Inbox } from 'lucide-react'
|
import { RefreshCw, X, Loader2, StopCircle, Clock, ScanLine, Network, RadioTower, Server, Inbox } from 'lucide-react'
|
||||||
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { scanApi } from '@/api/client'
|
import { scanApi } from '@/api/client'
|
||||||
@@ -22,17 +22,21 @@ interface ScanHistoryModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
|
type KindFilter = 'all' | 'ip' | 'zigbee' | 'zwave' | 'proxmox'
|
||||||
|
|
||||||
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
/** Normalise a ScanRun.kind into one of the known display kinds. */
|
||||||
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
|
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' | 'proxmox' {
|
||||||
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
|
return kind === 'zigbee' ? 'zigbee'
|
||||||
|
: kind === 'zwave' ? 'zwave'
|
||||||
|
: kind === 'proxmox' ? 'proxmox'
|
||||||
|
: 'ip'
|
||||||
}
|
}
|
||||||
|
|
||||||
const KIND_META = {
|
const KIND_META = {
|
||||||
ip: { label: 'IP', color: '#a855f7' },
|
ip: { label: 'IP', color: '#a855f7' },
|
||||||
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
zigbee: { label: 'Zigbee', color: '#00d4ff' },
|
||||||
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
zwave: { label: 'Z-Wave', color: '#ff6e00' },
|
||||||
|
proxmox: { label: 'Proxmox', color: '#e57000' },
|
||||||
} as const
|
} as const
|
||||||
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
|
||||||
|
|
||||||
@@ -49,6 +53,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
|
|||||||
{ key: 'ip', label: 'IP' },
|
{ key: 'ip', label: 'IP' },
|
||||||
{ key: 'zigbee', label: 'Zigbee' },
|
{ key: 'zigbee', label: 'Zigbee' },
|
||||||
{ key: 'zwave', label: 'Z-Wave' },
|
{ key: 'zwave', label: 'Z-Wave' },
|
||||||
|
{ key: 'proxmox', label: 'Proxmox' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function statusColor(s: string): string {
|
function statusColor(s: string): string {
|
||||||
@@ -102,9 +107,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
|
||||||
}
|
}
|
||||||
if (prev?.status === 'running' && run.status === 'done') {
|
if (prev?.status === 'running' && run.status === 'done') {
|
||||||
if (run.kind === 'zigbee' || run.kind === 'zwave') {
|
if (run.kind === 'zigbee' || run.kind === 'zwave' || run.kind === 'proxmox') {
|
||||||
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
|
const label = run.kind === 'zwave' ? 'Z-Wave' : run.kind === 'proxmox' ? 'Proxmox' : 'Zigbee'
|
||||||
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
// A done run can still carry a non-fatal advisory (e.g. Proxmox
|
||||||
|
// imported hosts but the token couldn't see any VMs/LXC).
|
||||||
|
if (run.error) {
|
||||||
|
toast.warning(`${label} import: ${run.error}`)
|
||||||
|
} else {
|
||||||
|
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
useCanvasStore.getState().notifyScanDeviceFound()
|
useCanvasStore.getState().notifyScanDeviceFound()
|
||||||
}
|
}
|
||||||
@@ -231,7 +242,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
{filtered.map((r) => {
|
{filtered.map((r) => {
|
||||||
const kind = runKind(r.kind)
|
const kind = runKind(r.kind)
|
||||||
const meta = KIND_META[kind]
|
const meta = KIND_META[kind]
|
||||||
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : ScanLine
|
const KindIcon = kind === 'zigbee' ? Network : kind === 'zwave' ? RadioTower : kind === 'proxmox' ? Server : ScanLine
|
||||||
return (
|
return (
|
||||||
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -286,7 +297,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{r.error && (
|
{r.error && (
|
||||||
<div className="mt-2 text-[11px] text-[#f85149] leading-tight whitespace-pre-wrap break-words rounded bg-[#f85149]/10 px-2 py-1.5">
|
// A 'done' run with a message is a non-fatal advisory → amber,
|
||||||
|
// not the red used for a genuine failure.
|
||||||
|
<div
|
||||||
|
className={`mt-2 text-[11px] leading-tight whitespace-pre-wrap break-words rounded px-2 py-1.5 ${
|
||||||
|
r.status === 'done'
|
||||||
|
? 'text-[#e3b341] bg-[#e3b341]/10'
|
||||||
|
: 'text-[#f85149] bg-[#f85149]/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{r.error}
|
{r.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { settingsApi } from '@/api/client'
|
import {
|
||||||
|
settingsApi,
|
||||||
|
proxmoxApi,
|
||||||
|
zigbeeApi,
|
||||||
|
zwaveApi,
|
||||||
|
type ProxmoxConfigData,
|
||||||
|
type ZigbeeConfigData,
|
||||||
|
type ZwaveConfigData,
|
||||||
|
} from '@/api/client'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +18,12 @@ import {
|
|||||||
writeAlignmentSettings,
|
writeAlignmentSettings,
|
||||||
subscribeAlignmentSettings,
|
subscribeAlignmentSettings,
|
||||||
} from '@/utils/alignmentSettings'
|
} from '@/utils/alignmentSettings'
|
||||||
|
import {
|
||||||
|
type AutosaveSettings,
|
||||||
|
readAutosaveSettings,
|
||||||
|
writeAutosaveSettings,
|
||||||
|
subscribeAutosaveSettings,
|
||||||
|
} from '@/utils/autosaveSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
@@ -18,12 +32,105 @@ interface SettingsModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MeshAutoSyncProps {
|
||||||
|
title: string
|
||||||
|
accent: string
|
||||||
|
hostConfigured: boolean
|
||||||
|
envHostVar: string
|
||||||
|
enabled: boolean
|
||||||
|
onEnabledChange: (v: boolean) => void
|
||||||
|
interval: number
|
||||||
|
onIntervalChange: (v: number) => void
|
||||||
|
description: string
|
||||||
|
syncing: boolean
|
||||||
|
onSyncNow: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-sync controls for an MQTT mesh import (Zigbee / Z-Wave). Mirrors the
|
||||||
|
* Proxmox auto-sync block: connection config is env-only, so this only toggles
|
||||||
|
* the scheduled activation + interval and offers an immediate re-sync. When no
|
||||||
|
* MQTT host is set in the server env, it shows how to configure one instead.
|
||||||
|
*/
|
||||||
|
function MeshAutoSync({
|
||||||
|
title, accent, hostConfigured, envHostVar, enabled, onEnabledChange,
|
||||||
|
interval, onIntervalChange, description, syncing, onSyncNow,
|
||||||
|
}: MeshAutoSyncProps) {
|
||||||
|
return (
|
||||||
|
<div className="pt-3 border-t border-border space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">{title}</span>
|
||||||
|
{!hostConfigured ? (
|
||||||
|
<p className="text-[10px] text-[#e3b341] leading-tight">
|
||||||
|
No MQTT host configured. Set <span className="font-mono">{envHostVar}</span> in the server .env to enable auto-sync.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Auto-sync {title.replace(' auto-sync', '')} inventory</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onEnabledChange(e.target.checked)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ accentColor: accent }}
|
||||||
|
aria-label={`Toggle ${title}`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Sync interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={300}
|
||||||
|
max={86400}
|
||||||
|
value={interval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) onIntervalChange(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none"
|
||||||
|
aria-label={`${title} interval`}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onSyncNow}
|
||||||
|
disabled={syncing}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
style={{ borderColor: accent, color: accent }}
|
||||||
|
>
|
||||||
|
{syncing ? 'Syncing…' : 'Re-sync now'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Runs one import immediately using the server .env config.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
||||||
const [interval, setIntervalValue] = useState(60)
|
const [interval, setIntervalValue] = useState(60)
|
||||||
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
|
||||||
const [serviceInterval, setServiceInterval] = useState(300)
|
const [serviceInterval, setServiceInterval] = useState(300)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [pmConfig, setPmConfig] = useState<ProxmoxConfigData | null>(null)
|
||||||
|
const [pmSyncEnabled, setPmSyncEnabled] = useState(false)
|
||||||
|
const [pmInterval, setPmInterval] = useState(3600)
|
||||||
|
const [pmSyncing, setPmSyncing] = useState(false)
|
||||||
|
const [zbConfig, setZbConfig] = useState<ZigbeeConfigData | null>(null)
|
||||||
|
const [zbSyncEnabled, setZbSyncEnabled] = useState(false)
|
||||||
|
const [zbInterval, setZbInterval] = useState(3600)
|
||||||
|
const [zbSyncing, setZbSyncing] = useState(false)
|
||||||
|
const [zwConfig, setZwConfig] = useState<ZwaveConfigData | null>(null)
|
||||||
|
const [zwSyncEnabled, setZwSyncEnabled] = useState(false)
|
||||||
|
const [zwInterval, setZwInterval] = useState(3600)
|
||||||
|
const [zwSyncing, setZwSyncing] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
@@ -36,9 +143,31 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
setServiceInterval(res.data.service_check_interval)
|
setServiceInterval(res.data.service_check_interval)
|
||||||
})
|
})
|
||||||
.catch(() => {/* use default */})
|
.catch(() => {/* use default */})
|
||||||
|
proxmoxApi.getConfig()
|
||||||
|
.then((res) => {
|
||||||
|
setPmConfig(res.data)
|
||||||
|
setPmSyncEnabled(res.data.sync_enabled)
|
||||||
|
setPmInterval(res.data.sync_interval)
|
||||||
|
})
|
||||||
|
.catch(() => {/* proxmox not configured */})
|
||||||
|
zigbeeApi.getConfig()
|
||||||
|
.then((res) => {
|
||||||
|
setZbConfig(res.data)
|
||||||
|
setZbSyncEnabled(res.data.sync_enabled)
|
||||||
|
setZbInterval(res.data.sync_interval)
|
||||||
|
})
|
||||||
|
.catch(() => {/* zigbee not configured */})
|
||||||
|
zwaveApi.getConfig()
|
||||||
|
.then((res) => {
|
||||||
|
setZwConfig(res.data)
|
||||||
|
setZwSyncEnabled(res.data.sync_enabled)
|
||||||
|
setZwInterval(res.data.sync_interval)
|
||||||
|
})
|
||||||
|
.catch(() => {/* zwave not configured */})
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
const next = { ...alignment, ...patch }
|
const next = { ...alignment, ...patch }
|
||||||
@@ -46,6 +175,48 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
writeAlignmentSettings(next)
|
writeAlignmentSettings(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateAutosave = (patch: Partial<AutosaveSettings>) => {
|
||||||
|
const next = { ...autosave, ...patch }
|
||||||
|
setAutosave(next)
|
||||||
|
writeAutosaveSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSyncNow = async () => {
|
||||||
|
setPmSyncing(true)
|
||||||
|
try {
|
||||||
|
await proxmoxApi.syncNow()
|
||||||
|
toast.success('Proxmox sync started')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start Proxmox sync')
|
||||||
|
} finally {
|
||||||
|
setPmSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZbSyncNow = async () => {
|
||||||
|
setZbSyncing(true)
|
||||||
|
try {
|
||||||
|
await zigbeeApi.syncNow()
|
||||||
|
toast.success('Zigbee sync started')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start Zigbee sync')
|
||||||
|
} finally {
|
||||||
|
setZbSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleZwSyncNow = async () => {
|
||||||
|
setZwSyncing(true)
|
||||||
|
try {
|
||||||
|
await zwaveApi.syncNow()
|
||||||
|
toast.success('Z-Wave sync started')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to start Z-Wave sync')
|
||||||
|
} finally {
|
||||||
|
setZwSyncing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
|
||||||
// status-check interval needs an API round-trip.
|
// status-check interval needs an API round-trip.
|
||||||
@@ -60,6 +231,27 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
service_check_enabled: serviceCheckEnabled,
|
service_check_enabled: serviceCheckEnabled,
|
||||||
service_check_interval: serviceInterval,
|
service_check_interval: serviceInterval,
|
||||||
})
|
})
|
||||||
|
if (pmConfig) {
|
||||||
|
// Connection config (host/port/token/verify) is env-only; only the
|
||||||
|
// auto-sync activation is persisted.
|
||||||
|
await proxmoxApi.saveConfig({
|
||||||
|
sync_enabled: pmSyncEnabled,
|
||||||
|
sync_interval: pmInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (zbConfig) {
|
||||||
|
// MQTT connection config is env-only; only the activation is persisted.
|
||||||
|
await zigbeeApi.saveConfig({
|
||||||
|
sync_enabled: zbSyncEnabled,
|
||||||
|
sync_interval: zbInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (zwConfig) {
|
||||||
|
await zwaveApi.saveConfig({
|
||||||
|
sync_enabled: zwSyncEnabled,
|
||||||
|
sync_interval: zwInterval,
|
||||||
|
})
|
||||||
|
}
|
||||||
toast.success('Settings saved')
|
toast.success('Settings saved')
|
||||||
onClose()
|
onClose()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -71,12 +263,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="bg-[#161b22] border-border max-w-md">
|
<DialogContent className="bg-[#161b22] border-border max-w-[calc(100%-2rem)] sm:max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
<DialogTitle className="text-foreground">Settings</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-5 py-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5 py-2">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="space-y-5">
|
||||||
{/* Status checker */}
|
{/* Status checker */}
|
||||||
{!STANDALONE && (
|
{!STANDALONE && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -173,6 +367,141 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Autosave canvas</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autosave.enabled}
|
||||||
|
onChange={(e) => updateAutosave({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle autosave"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={autosave.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Save after</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={autosave.delay}
|
||||||
|
onChange={(e) => updateAutosave({ delay: Number(e.target.value) })}
|
||||||
|
disabled={!autosave.enabled}
|
||||||
|
className="px-2 py-1 rounded-md text-xs bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Autosave delay"
|
||||||
|
>
|
||||||
|
<option value={3}>3 s</option>
|
||||||
|
<option value={5}>5 s</option>
|
||||||
|
<option value={10}>10 s</option>
|
||||||
|
<option value={30}>30 s</option>
|
||||||
|
<option value={60}>60 s</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">of inactivity</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Zigbee auto-sync */}
|
||||||
|
{!STANDALONE && zbConfig && (
|
||||||
|
<MeshAutoSync
|
||||||
|
title="Zigbee auto-sync"
|
||||||
|
accent="#39d353"
|
||||||
|
hostConfigured={zbConfig.host_configured}
|
||||||
|
envHostVar="ZIGBEE_MQTT_HOST"
|
||||||
|
enabled={zbSyncEnabled}
|
||||||
|
onEnabledChange={setZbSyncEnabled}
|
||||||
|
interval={zbInterval}
|
||||||
|
onIntervalChange={setZbInterval}
|
||||||
|
description="Re-imports the Zigbee mesh into the pending inventory. Min 300s (5 min)."
|
||||||
|
syncing={zbSyncing}
|
||||||
|
onSyncNow={handleZbSyncNow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Z-Wave auto-sync */}
|
||||||
|
{!STANDALONE && zwConfig && (
|
||||||
|
<MeshAutoSync
|
||||||
|
title="Z-Wave auto-sync"
|
||||||
|
accent="#a855f7"
|
||||||
|
hostConfigured={zwConfig.host_configured}
|
||||||
|
envHostVar="ZWAVE_MQTT_HOST"
|
||||||
|
enabled={zwSyncEnabled}
|
||||||
|
onEnabledChange={setZwSyncEnabled}
|
||||||
|
interval={zwInterval}
|
||||||
|
onIntervalChange={setZwInterval}
|
||||||
|
description="Re-imports the Z-Wave network into the pending inventory. Min 300s (5 min)."
|
||||||
|
syncing={zwSyncing}
|
||||||
|
onSyncNow={handleZwSyncNow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Proxmox auto-sync */}
|
||||||
|
{!STANDALONE && pmConfig && (
|
||||||
|
<div className="pt-3 border-t border-border space-y-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Proxmox auto-sync</span>
|
||||||
|
{!pmConfig.token_configured ? (
|
||||||
|
<p className="text-[10px] text-[#e3b341] leading-tight">
|
||||||
|
No API token configured. Set <span className="font-mono">PROXMOX_TOKEN_ID</span> and{' '}
|
||||||
|
<span className="font-mono">PROXMOX_TOKEN_SECRET</span> in the server .env to enable auto-sync.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Auto-sync Proxmox inventory</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={pmSyncEnabled}
|
||||||
|
onChange={(e) => setPmSyncEnabled(e.target.checked)}
|
||||||
|
className="cursor-pointer accent-[#e57000]"
|
||||||
|
aria-label="Toggle Proxmox auto-sync"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className={pmSyncEnabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Sync interval (s)</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={300}
|
||||||
|
max={86400}
|
||||||
|
value={pmInterval}
|
||||||
|
onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setPmInterval(v) }}
|
||||||
|
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#e57000]"
|
||||||
|
aria-label="Proxmox sync interval"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">seconds</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Re-imports hosts/VMs/LXC into the pending inventory. Min 300s (5 min).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{pmConfig.host ? (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSyncNow}
|
||||||
|
disabled={pmSyncing}
|
||||||
|
className="h-7 text-xs border-[#e57000] text-[#e57000] hover:bg-[#e57000]/10"
|
||||||
|
>
|
||||||
|
{pmSyncing ? 'Syncing…' : 'Re-sync now'}
|
||||||
|
</Button>
|
||||||
|
<span className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Runs one import immediately using the server .env config.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-[#e3b341] leading-tight pt-1">
|
||||||
|
Set <span className="font-mono">PROXMOX_HOST</span> in the server .env to enable manual re-sync.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CustomStyleModal } from '../CustomStyleModal'
|
|||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
|
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
describe('CustomStyleModal', () => {
|
describe('CustomStyleModal', () => {
|
||||||
@@ -54,6 +54,13 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(screen.getByText('Default size')).toBeDefined()
|
expect(screen.getByText('Default size')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('initialNodeType preselects that type editor on open (NodeModal shortcut)', () => {
|
||||||
|
render(<CustomStyleModal open initialNodeType="switch" onClose={vi.fn()} />)
|
||||||
|
// Editor for Switch is shown immediately, no manual selection needed.
|
||||||
|
expect(screen.getByText(/Apply to existing Switch/)).toBeDefined()
|
||||||
|
expect(screen.queryByText(/Select a node type/)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
it('selecting an edge type opens the edge editor with path style buttons', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
@@ -117,6 +124,41 @@ describe('CustomStyleModal', () => {
|
|||||||
expect(markUnsaved).not.toHaveBeenCalled()
|
expect(markUnsaved).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('edge editor exposes Start/End marker pickers defaulting to none', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
const startNone = screen.getByRole('button', { name: 'Start marker none' })
|
||||||
|
const endNone = screen.getByRole('button', { name: 'End marker none' })
|
||||||
|
expect(startNone.getAttribute('aria-pressed')).toBe('true')
|
||||||
|
expect(endNone.getAttribute('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picking an End shape feeds arrowEnd to applyTypeEdgeStyle', () => {
|
||||||
|
const applyTypeEdgeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'End marker diamond' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond')
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picking a line style + width feeds lineStyle/widthMult to applyTypeEdgeStyle', () => {
|
||||||
|
const applyTypeEdgeStyle = vi.fn()
|
||||||
|
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Dotted' }))
|
||||||
|
fireEvent.change(screen.getByRole('slider', { name: 'Line width multiplier' }), { target: { value: '3' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][1].lineStyle).toBe('dotted')
|
||||||
|
expect(applyTypeEdgeStyle.mock.calls[0][1].widthMult).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
it('editing path style updates the edge draft', () => {
|
it('editing path style updates the edge draft', () => {
|
||||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||||
@@ -135,6 +177,34 @@ describe('CustomStyleModal', () => {
|
|||||||
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows per-side default connection-point inputs in the node editor', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect(screen.getByText('Default connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Top default connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Left default connection points')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults per-side inputs to 1 (top/bottom) and 0 (left/right)', () => {
|
||||||
|
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
expect((screen.getByLabelText('Top default connection points') as HTMLInputElement).value).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left default connection points') as HTMLInputElement).value).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('editing a per-side default persists via setCustomStyle on Save', () => {
|
||||||
|
const onClose = vi.fn()
|
||||||
|
useCanvasStore.setState({ markUnsaved: vi.fn() })
|
||||||
|
const setCustomStyle = vi.spyOn(useThemeStore.getState(), 'setCustomStyle')
|
||||||
|
render(<CustomStyleModal open onClose={onClose} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
|
||||||
|
fireEvent.change(screen.getByLabelText('Left default connection points'), { target: { value: '3' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
|
||||||
|
expect(setCustomStyle).toHaveBeenCalled()
|
||||||
|
const saved = setCustomStyle.mock.calls.at(-1)?.[0]
|
||||||
|
expect(saved?.nodes.router?.leftHandles).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
|
||||||
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
// Parent keeps the modal mounted and only toggles `open`, so the reset must
|
||||||
// happen on the open-prop edge, not via Radix onOpenChange.
|
// happen on the open-prop edge, not via Radix onOpenChange.
|
||||||
|
|||||||
@@ -64,4 +64,199 @@ describe('DesignModal', () => {
|
|||||||
expect(onClose).toHaveBeenCalled()
|
expect(onClose).toHaveBeenCalled()
|
||||||
expect(onSubmit).not.toHaveBeenCalled()
|
expect(onSubmit).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('copy from existing', () => {
|
||||||
|
const sourceDesigns = [
|
||||||
|
{ id: 's1', name: 'Home Net', icon: 'network', design_type: 'network' as const,
|
||||||
|
created_at: '', updated_at: '', node_count: 4, group_count: 1, text_count: 2 },
|
||||||
|
{ id: 's2', name: 'Lab', icon: 'server', design_type: 'network' as const,
|
||||||
|
created_at: '', updated_at: '', node_count: 7, group_count: 0, text_count: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
it('offers no copy option when there are no source designs', () => {
|
||||||
|
renderModal({ sourceDesigns: [] })
|
||||||
|
expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the source list with counts once "Copy from existing" is chosen', () => {
|
||||||
|
renderModal({ sourceDesigns })
|
||||||
|
// Hidden until the user opts into copying.
|
||||||
|
expect(screen.queryByText('Home Net')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||||
|
expect(screen.getByText('Home Net')).toBeDefined()
|
||||||
|
expect(screen.getByText('4 nodes · 1 groups · 2 text')).toBeDefined()
|
||||||
|
expect(screen.getByText('7 nodes · 0 groups · 0 text')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes sourceId (first design by default) on submit', () => {
|
||||||
|
const { onSubmit } = renderModal({ sourceDesigns })
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Home' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Home', icon: DEFAULT_DESIGN_ICON, sourceId: 's1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes the picked sourceId on submit', () => {
|
||||||
|
const { onSubmit } = renderModal({ sourceDesigns })
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Copy of Lab' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Copy from existing' }))
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: /Lab/ }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: 'Copy of Lab', icon: DEFAULT_DESIGN_ICON, sourceId: 's2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits sourceId when the blank option is kept', () => {
|
||||||
|
const { onSubmit } = renderModal({ sourceDesigns })
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Fresh' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: 'Fresh', icon: DEFAULT_DESIGN_ICON })
|
||||||
|
expect('sourceId' in onSubmit.mock.calls[0][0]).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden in edit mode (floor-plan shown)', () => {
|
||||||
|
renderModal({ sourceDesigns, showFloorMap: true, initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON } })
|
||||||
|
expect(screen.queryByRole('button', { name: 'Copy from existing' })).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('floor plan section', () => {
|
||||||
|
const fm = {
|
||||||
|
imageData: 'data:image/png;base64,abc',
|
||||||
|
posX: 40, posY: 60, width: 800, height: 600,
|
||||||
|
opacity: 0.8, locked: false, enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is hidden by default and submit omits floorMap', () => {
|
||||||
|
const { onSubmit } = renderModal()
|
||||||
|
expect(screen.queryByText('Floor Plan')).toBeNull()
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
|
||||||
|
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the section and preserves position while updating config', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
showFloorMap: true,
|
||||||
|
initialFloorMap: fm,
|
||||||
|
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
|
||||||
|
submitLabel: 'Save',
|
||||||
|
})
|
||||||
|
expect(screen.getByText('Floor Plan')).toBeDefined()
|
||||||
|
expect(screen.getByAltText('Floor plan preview')).toBeDefined()
|
||||||
|
|
||||||
|
// Toggle "Show on canvas" off.
|
||||||
|
const enabledBox = screen.getByLabelText('Show on canvas') as HTMLInputElement
|
||||||
|
fireEvent.click(enabledBox)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Home',
|
||||||
|
icon: DEFAULT_DESIGN_ICON,
|
||||||
|
floorMap: { ...fm, enabled: false },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits floorMap: null when the image is removed', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
showFloorMap: true,
|
||||||
|
initialFloorMap: fm,
|
||||||
|
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
|
||||||
|
submitLabel: 'Save',
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||||
|
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({
|
||||||
|
name: 'Home',
|
||||||
|
icon: DEFAULT_DESIGN_ICON,
|
||||||
|
floorMap: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uploads a chosen file and stores the returned server URL', async () => {
|
||||||
|
const onUploadImage = vi.fn().mockResolvedValue('/api/v1/media/deadbeef.png')
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
showFloorMap: true,
|
||||||
|
initialFloorMap: null,
|
||||||
|
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
|
||||||
|
submitLabel: 'Save',
|
||||||
|
onUploadImage,
|
||||||
|
})
|
||||||
|
const file = new File(['x'], 'plan.png', { type: 'image/png' })
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||||
|
fireEvent.change(input, { target: { files: [file] } })
|
||||||
|
|
||||||
|
await screen.findByAltText('Floor plan preview')
|
||||||
|
expect(onUploadImage).toHaveBeenCalledWith(file)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0]
|
||||||
|
expect(submitted.floorMap.imageData).toBe('/api/v1/media/deadbeef.png')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves state untouched when upload fails', async () => {
|
||||||
|
const onUploadImage = vi.fn().mockRejectedValue(new Error('boom'))
|
||||||
|
renderModal({
|
||||||
|
showFloorMap: true,
|
||||||
|
initialFloorMap: null,
|
||||||
|
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
|
||||||
|
submitLabel: 'Save',
|
||||||
|
onUploadImage,
|
||||||
|
})
|
||||||
|
const file = new File(['x'], 'plan.png', { type: 'image/png' })
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||||
|
fireEvent.change(input, { target: { files: [file] } })
|
||||||
|
await vi.waitFor(() => expect(onUploadImage).toHaveBeenCalled())
|
||||||
|
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Regression: reopening the edit modal after a canvas-side resize must not
|
||||||
|
// save stale dimensions. Sidebar bumps the modal `key` on every open so it
|
||||||
|
// remounts and re-seeds from the current floor plan.
|
||||||
|
it('re-seeds width/height when remounted with a new key (reopen after resize)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
|
||||||
|
const { rerender } = render(
|
||||||
|
<DesignModal key="k1" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
// Canvas-side resize happened; reopen with a fresh key + larger dims.
|
||||||
|
const resized = { ...fm, width: 1200, height: 900 }
|
||||||
|
rerender(
|
||||||
|
<DesignModal key="k2" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 1200, height: 900 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps stale dimensions when reopened without remount (why the key bump matters)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
|
||||||
|
const { rerender } = render(
|
||||||
|
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
const resized = { ...fm, width: 1200, height: 900 }
|
||||||
|
rerender(
|
||||||
|
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
|
||||||
|
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
// Same key → no remount → local state still the original 800×600.
|
||||||
|
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 800, height: 600 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits floorMap: null when shown but no image was chosen', () => {
|
||||||
|
const { onSubmit } = renderModal({
|
||||||
|
showFloorMap: true,
|
||||||
|
initialFloorMap: null,
|
||||||
|
submitLabel: 'Save',
|
||||||
|
})
|
||||||
|
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Empty' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
|
expect(onSubmit).toHaveBeenCalledWith({ name: 'Empty', icon: DEFAULT_DESIGN_ICON, floorMap: null })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -125,6 +125,41 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
|
expect(onSubmit.mock.calls[0][0].path_style).toBe('smooth')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Line style + width ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('defaults line style to the edge type preset (ethernet → solid)', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].line_style).toBe('solid')
|
||||||
|
expect(onSubmit.mock.calls[0][0].width_mult).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('follows the type default (wifi → dashed) until overridden', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ type: 'wifi' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].line_style).toBe('dashed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picking a line style + width sends line_style/width_mult', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Line style dotted' }))
|
||||||
|
fireEvent.change(screen.getByRole('slider', { name: 'Line width multiplier' }), { target: { value: '4' } })
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].line_style).toBe('dotted')
|
||||||
|
expect(onSubmit.mock.calls[0][0].width_mult).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills line style + width from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ line_style: 'dashed', width_mult: 3 }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].line_style).toBe('dashed')
|
||||||
|
expect(onSubmit.mock.calls[0][0].width_mult).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
// ── Animation select ──────────────────────────────────────────────────────
|
// ── Animation select ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('animation defaults to None — animated omitted from payload', () => {
|
it('animation defaults to None — animated omitted from payload', () => {
|
||||||
@@ -165,6 +200,58 @@ describe('EdgeModal', () => {
|
|||||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Endpoint markers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('endpoints default to none', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picking an End arrow sends marker_end: "arrow"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('picking a Start circle sends marker_start: "circle"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('circle')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows a different shape on each end', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Start marker diamond' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'End marker square' }))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('square')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills endpoint shapes from initial', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: 'diamond', marker_end: 'arrow-open' }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow-open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('coerces a legacy boolean initial marker to "arrow"', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_end: true }} />)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
|
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||||
|
})
|
||||||
|
|
||||||
it('selecting None after Snake omits animated from payload', () => {
|
it('selecting None after Snake omits animated from payload', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ describe('GroupRectModal', () => {
|
|||||||
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders two-column Content and Style section headers', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Content')).toBeDefined()
|
||||||
|
expect(screen.getByText('Style')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders Edit Zone title when provided', () => {
|
it('renders Edit Zone title when provided', () => {
|
||||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Zone" />)
|
||||||
expect(screen.getByText('Edit Zone')).toBeDefined()
|
expect(screen.getByText('Edit Zone')).toBeDefined()
|
||||||
|
|||||||
@@ -105,6 +105,21 @@ describe('NodeModal', () => {
|
|||||||
confirmSpy.mockRestore()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Custom-style shortcut ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the type-style shortcut and calls onEditTypeStyle with the node type', () => {
|
||||||
|
const onEditTypeStyle = vi.fn()
|
||||||
|
renderModal({ initial: BASE, onEditTypeStyle })
|
||||||
|
const link = screen.getByRole('button', { name: /style for all nodes on the canvas/i })
|
||||||
|
fireEvent.click(link)
|
||||||
|
expect(onEditTypeStyle).toHaveBeenCalledWith('server')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('omits the shortcut when onEditTypeStyle is not provided', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect(screen.queryByText(/style for all nodes on the canvas/i)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
// ── Label validation ──────────────────────────────────────────────────
|
// ── Label validation ──────────────────────────────────────────────────
|
||||||
|
|
||||||
it('blocks submit and shows error when label is empty', () => {
|
it('blocks submit and shows error when label is empty', () => {
|
||||||
@@ -435,57 +450,88 @@ describe('NodeModal', () => {
|
|||||||
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
expect(screen.getByText(/Using default colors for/)).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Bottom connection points ───────────────────────────────────────────
|
// ── Connection points per side (issue #243) ────────────────────────────
|
||||||
|
|
||||||
it('shows Bottom Connection Points for server type', () => {
|
it('shows the Connection Points section for server type', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
expect(screen.getByText('Bottom Connection Points')).toBeDefined()
|
expect(screen.getByText('Connection Points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Top connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Right connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Bottom connection points')).toBeDefined()
|
||||||
|
expect(screen.getByLabelText('Left connection points')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Bottom Connection Points for groupRect', () => {
|
it('hides Connection Points for groupRect', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
renderModal({ initial: { ...BASE, type: 'groupRect' } })
|
||||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
expect(screen.queryByText('Connection Points')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides Bottom Connection Points for group', () => {
|
it('hides Connection Points for group', () => {
|
||||||
renderModal({ initial: { ...BASE, type: 'group' } })
|
renderModal({ initial: { ...BASE, type: 'group' } })
|
||||||
expect(screen.queryByText('Bottom Connection Points')).toBeNull()
|
expect(screen.queryByText('Connection Points')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('defaults bottom_handles to 1', () => {
|
it('defaults top/bottom to 1 and left/right to 0', () => {
|
||||||
renderModal({ initial: BASE })
|
renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('1')
|
||||||
expect(slider.value).toBe('1')
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('0')
|
||||||
|
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('0')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('pre-fills bottom_handles from initial', () => {
|
it('pre-fills each side from initial', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
|
renderModal({ initial: { ...BASE, top_handles: 2, bottom_handles: 3, left_handles: 4, right_handles: 1 } })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('2')
|
||||||
expect(slider.value).toBe('3')
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('3')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).value).toBe('4')
|
||||||
|
expect((screen.getByLabelText('Right connection points') as HTMLInputElement).value).toBe('1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('submits updated bottom_handles', () => {
|
it('submits per-side counts edited via the number inputs', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '12' } })
|
||||||
fireEvent.change(slider, { target: { value: '12' } })
|
fireEvent.change(screen.getByLabelText('Left connection points'), { target: { value: '3' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(12)
|
const payload = onSubmit.mock.calls[0][0] as Partial<NodeData>
|
||||||
|
expect(payload.bottom_handles).toBe(12)
|
||||||
|
expect(payload.left_handles).toBe(3)
|
||||||
|
expect(payload.top_handles).toBe(1)
|
||||||
|
expect(payload.right_handles).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('supports the full 1..64 range (issue #20)', () => {
|
it('increments a side with the + stepper button', () => {
|
||||||
const { onSubmit } = renderModal({ initial: BASE })
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||||
expect(slider.min).toBe('1')
|
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
|
||||||
expect(slider.max).toBe('64')
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
fireEvent.change(slider, { target: { value: '52' } })
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).right_handles).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the − button at the side minimum (0 for left/right, 1 for top/bottom)', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect((screen.getByRole('button', { name: 'Decrease Left connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('left/right min is 0, top/bottom min is 1; max is 64 everywhere', () => {
|
||||||
|
renderModal({ initial: BASE })
|
||||||
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('1')
|
||||||
|
expect((screen.getByLabelText('Left connection points') as HTMLInputElement).min).toBe('0')
|
||||||
|
for (const label of ['Top', 'Right', 'Bottom', 'Left']) {
|
||||||
|
expect((screen.getByLabelText(`${label} connection points`) as HTMLInputElement).max).toBe('64')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports the full range up to 64 (issue #20)', () => {
|
||||||
|
const { onSubmit } = renderModal({ initial: BASE })
|
||||||
|
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '52' } })
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||||
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).bottom_handles).toBe(52)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('clamps pre-filled out-of-range values into [1,64]', () => {
|
it('clamps pre-filled out-of-range values into range', () => {
|
||||||
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
renderModal({ initial: { ...BASE, bottom_handles: 9999 } })
|
||||||
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
|
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('64')
|
||||||
expect(slider.value).toBe('64')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
it('toggles show_port_numbers and submits it (issue #20)', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const mockHide = vi.fn()
|
|||||||
const mockPending = vi.fn()
|
const mockPending = vi.fn()
|
||||||
const mockHidden = vi.fn()
|
const mockHidden = vi.fn()
|
||||||
const mockAddNode = vi.fn()
|
const mockAddNode = vi.fn()
|
||||||
|
const mockSetSelectedNode = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/api/client', () => ({
|
vi.mock('@/api/client', () => ({
|
||||||
scanApi: {
|
scanApi: {
|
||||||
@@ -30,11 +31,15 @@ vi.mock('@/api/client', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
|
||||||
|
|
||||||
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
vi.mock('@/components/modals/PendingDeviceModal', () => ({
|
||||||
PendingDeviceModal: ({ device }: { device: unknown }) =>
|
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
|
||||||
device ? <div data-testid="approval-modal" /> : null,
|
device ? (
|
||||||
|
<div data-testid="approval-modal">
|
||||||
|
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const DEVICE_IP = {
|
const DEVICE_IP = {
|
||||||
@@ -84,12 +89,32 @@ const DEVICE_ZWAVE = {
|
|||||||
discovered_at: '2026-01-03T00:00:00Z',
|
discovered_at: '2026-01-03T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEVICE_PROXMOX = {
|
||||||
|
id: 'dev-d',
|
||||||
|
ip: '10.0.0.5',
|
||||||
|
hostname: 'web',
|
||||||
|
mac: null,
|
||||||
|
os: null,
|
||||||
|
services: [],
|
||||||
|
suggested_type: 'vm',
|
||||||
|
status: 'pending',
|
||||||
|
discovery_source: 'proxmox',
|
||||||
|
ieee_address: 'pve-pve1-101',
|
||||||
|
friendly_name: 'web',
|
||||||
|
vendor: 'Proxmox VE',
|
||||||
|
model: 'QEMU',
|
||||||
|
properties: [{ key: 'CPU Cores', value: '2', icon: 'Cpu', visible: false }],
|
||||||
|
discovered_at: '2026-01-04T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.mocked(useCanvasStore).mockReturnValue({
|
// Apply the selector when one is passed (setSelectedNode is read via a
|
||||||
addNode: mockAddNode,
|
// selector), else return the whole store (destructured in the component).
|
||||||
scanEventTs: 0,
|
vi.mocked(useCanvasStore).mockImplementation(((sel?: (s: unknown) => unknown) => {
|
||||||
} as unknown as ReturnType<typeof useCanvasStore>)
|
const store = { addNode: mockAddNode, scanEventTs: 0, setSelectedNode: mockSetSelectedNode }
|
||||||
|
return sel ? sel(store) : store
|
||||||
|
}) as unknown as typeof useCanvasStore)
|
||||||
// setState is used by injectAutoEdges
|
// setState is used by injectAutoEdges
|
||||||
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
|
||||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||||
@@ -97,7 +122,7 @@ beforeEach(() => {
|
|||||||
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
|
||||||
mockHide.mockResolvedValue({ data: {} })
|
mockHide.mockResolvedValue({ data: {} })
|
||||||
mockBulkApprove.mockResolvedValue({
|
mockBulkApprove.mockResolvedValue({
|
||||||
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0 },
|
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], edges: [], edges_created: 0, skipped_devices: [] },
|
||||||
})
|
})
|
||||||
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
mockBulkHide.mockResolvedValue({ data: { hidden: 2, skipped: 0 } })
|
||||||
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
|
||||||
@@ -153,6 +178,18 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
expect(screen.getByText('Z-WAVE')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('colours the role badge with the node-type accent, not flat grey', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_ZWAVE] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
const card = await waitFor(() => screen.getByTestId('pending-card-dev-c'))
|
||||||
|
// zwave_router accent from the default theme = #e3b341 (amber), applied to
|
||||||
|
// both the text colour and a translucent background.
|
||||||
|
const badge = within(card).getByText('zwave_router')
|
||||||
|
// #e3b341 → rgb(227, 179, 65) once jsdom normalises the inline colour.
|
||||||
|
expect(badge).toHaveStyle({ color: 'rgb(227, 179, 65)' })
|
||||||
|
expect(badge.className).not.toContain('text-muted-foreground')
|
||||||
|
})
|
||||||
|
|
||||||
it('filters by source (zwave only)', async () => {
|
it('filters by source (zwave only)', async () => {
|
||||||
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
@@ -162,6 +199,23 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
expect(screen.getByTestId('pending-card-dev-c')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows source chip PROXMOX for a proxmox device (not zigbee despite ieee)', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_PROXMOX] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('ZIGBEE')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by source (proxmox only)', async () => {
|
||||||
|
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_PROXMOX] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
|
||||||
|
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-d')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('filters by suggested type', async () => {
|
it('filters by suggested type', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -188,6 +242,60 @@ describe('PendingDevicesModal', () => {
|
|||||||
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
expect(screen.getByTestId('approval-modal')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const DUP_409 = {
|
||||||
|
response: {
|
||||||
|
status: 409,
|
||||||
|
data: {
|
||||||
|
detail: {
|
||||||
|
duplicate: true,
|
||||||
|
existing_node_id: 'n-existing',
|
||||||
|
existing_label: 'Existing Srv',
|
||||||
|
match: 'ip',
|
||||||
|
value: '192.168.1.10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
it('single approve prompts instead of failing when the host is already on the design', async () => {
|
||||||
|
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('do-approve'))
|
||||||
|
// The duplicate dialog appears (not a silent failure).
|
||||||
|
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
|
||||||
|
expect(screen.getByText('Existing Srv')).toBeInTheDocument()
|
||||||
|
// Regression: the device-detail modal must close so it doesn't trap focus
|
||||||
|
// and hide the prompt (two stacked Base UI dialogs).
|
||||||
|
expect(screen.queryByTestId('approval-modal')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Add duplicate anyway" retries the approve with force=true', async () => {
|
||||||
|
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('do-approve'))
|
||||||
|
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Add duplicate anyway/ }))
|
||||||
|
await waitFor(() => expect(mockApprove).toHaveBeenCalledTimes(2))
|
||||||
|
expect(mockApprove).toHaveBeenLastCalledWith('dev-a', expect.objectContaining({ force: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Go to existing node" selects the existing node and closes the modal', async () => {
|
||||||
|
mockApprove.mockRejectedValueOnce(DUP_409)
|
||||||
|
const onClose = vi.fn()
|
||||||
|
render(<PendingDevicesModal {...baseProps} onClose={onClose} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('do-approve'))
|
||||||
|
await waitFor(() => expect(screen.getByText('Device already on this canvas')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Go to existing node/ }))
|
||||||
|
expect(mockSetSelectedNode).toHaveBeenCalledWith('n-existing')
|
||||||
|
expect(onClose).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('toggles selection in select mode instead of opening approval', async () => {
|
it('toggles selection in select mode instead of opening approval', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
@@ -216,6 +324,50 @@ describe('PendingDevicesModal', () => {
|
|||||||
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalledWith(['dev-a', 'dev-b'], null))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('bulk approve reports devices skipped as duplicates', async () => {
|
||||||
|
const { toast } = await import('sonner')
|
||||||
|
mockBulkApprove.mockResolvedValue({
|
||||||
|
data: {
|
||||||
|
approved: 1, node_ids: ['n1'], device_ids: ['dev-b'], edges: [], edges_created: 0,
|
||||||
|
skipped: 1,
|
||||||
|
skipped_devices: [{ device_id: 'dev-a', label: 'host-a', match: 'ip', value: '192.168.1.10', existing_node_id: 'n-existing' }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toast.info).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('1 already on this canvas'),
|
||||||
|
expect.anything(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps approved devices listed after bulk approve (reloads, not strips)', async () => {
|
||||||
|
// After approve, pending() still returns the rows (now on-canvas w/ badge).
|
||||||
|
mockPending
|
||||||
|
.mockResolvedValueOnce({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
|
||||||
|
.mockResolvedValue({ data: [
|
||||||
|
{ ...DEVICE_IP, canvas_count: 1 },
|
||||||
|
{ ...DEVICE_ZIGBEE, canvas_count: 1 },
|
||||||
|
] })
|
||||||
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Select mode' }))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-a'))
|
||||||
|
fireEvent.click(screen.getByTestId('pending-card-dev-b'))
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Approve \(2\)/ }))
|
||||||
|
await waitFor(() => expect(mockBulkApprove).toHaveBeenCalled())
|
||||||
|
// Reloaded, so rows remain visible instead of the list going empty.
|
||||||
|
await waitFor(() => expect(mockPending).toHaveBeenCalledTimes(2))
|
||||||
|
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('pending-card-dev-b')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
it('bulk approve carries the scanned MAC onto the canvas node (#168)', async () => {
|
||||||
render(<PendingDevicesModal {...baseProps} />)
|
render(<PendingDevicesModal {...baseProps} />)
|
||||||
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user