Compare commits

...

82 Commits

Author SHA1 Message Date
Pouzor - Rémy Jardient 228f2a67e4 Merge pull request #271 from Pouzor/release/3.0.0
chore: bump version to 3.0.0
2026-07-10 17:37:43 +02:00
Pouzor 9331bc43cb chore: bump version to 3.0.0
Release 3.0.0. See CHANGELOG.md for full details.
2026-07-10 17:06:20 +02:00
Pouzor - Rémy Jardient f98a55dca0 Merge pull request #270 from Pouzor/feat/zigbee-zwave-autosync
feat: scheduled auto-sync for Zigbee & Z-Wave imports
2026-07-10 16:20:02 +02:00
Pouzor 5139daef90 fix: satisfy mypy in _run_mesh_sync branch typing
CI mypy flagged the shared payload/background locals in _run_mesh_sync
as incompatibly typed across the zigbee/zwave branches. Annotate them
with the union / generic Callable and alias the per-branch
env_import_request imports so the two bindings don't clash.
2026-07-10 15:58:47 +02:00
Pouzor d3728f4108 fix: widen Settings modal to two columns; env cleanup
- SettingsModal: widen to sm:max-w-3xl with max-h-[90vh] scroll and a
  two-column grid so it no longer overflows the viewport. Left column =
  status/service checks + canvas prefs; right column groups all auto-sync
  config (Zigbee, Z-Wave, Proxmox).
- .env.example: drop ZIGBEE_/ZWAVE_ SYNC_ENABLED + SYNC_INTERVAL — the
  auto-sync activation is configured in the Settings modal (persisted to
  scan_config.json), same as Proxmox; connection config stays env-only.

ha-relevant: no
2026-07-10 15:45:46 +02:00
Pouzor b99450db2f feat: scheduled auto-sync for Zigbee & Z-Wave imports
Mirror the Proxmox auto-sync pattern for the Zigbee2MQTT and Z-Wave JS
UI mesh imports. Connection config + MQTT credentials live in .env only,
are never persisted to scan_config.json, and are never returned by any
API or shown in the UI (single source of truth).

- config: ZIGBEE_* / ZWAVE_* env settings; only sync_enabled+interval
  are persisted, connection/credentials stay env-only
- routes: GET/POST /config, POST /sync-now; auto-sync reuses the exact
  manual _background_*_import + _persist_pending_import path (fresh
  import when empty, update-in-place when nodes exist, ScanRun trace)
- scheduler: zigbee_sync / zwave_sync jobs with live enable + reschedule
- frontend: reusable MeshAutoSync section in Settings (Zigbee, Z-Wave)
- .env.example: documented both blocks
- tests: scheduler jobs, router config/sync-now/auth, credential-never-
  persisted, SettingsModal sections

Manual Zigbee/Z-Wave import behaviour is unchanged.

ha-relevant: no
2026-07-10 14:51:01 +02:00
Pouzor - Rémy Jardient a90ca2f039 Merge pull request #269 from Pouzor/fix/proxmox-autosync-scan-history
fix: record ScanRun for scheduled Proxmox auto-sync
2026-07-10 13:42:16 +02:00
Pouzor ad4aa4aba4 fix: record ScanRun for scheduled Proxmox auto-sync
The scheduled auto-sync job called _persist_pending_import directly and
never created a ScanRun, so auto-imports left no trace in Scan history
(unlike manual /sync-now and /import-pending, which both record a run).

Create a ScanRun(kind=proxmox) and delegate to the shared
_background_proxmox_import flow — fetch, persist, mark the run
done/error, and broadcast the inventory-reload signal.

ha-relevant: maybe
2026-07-10 12:47:16 +02:00
Pouzor - Rémy Jardient c4cb44709d Merge pull request #268 from Pouzor/refactor/frontend-test-consolidation
test: consolidate and restructure frontend tests
2026-07-10 10:26:24 +02:00
Pouzor 24e755a097 docs: add frontend test infra README (factories, mocks, split conventions)
ha-relevant: yes
2026-07-10 03:04:47 +02:00
Pouzor 075f7f6d78 test: dedup sonner mocks and close Toolbar seam-mock gaps
- Replace 10 inline sonner toast mocks with the shared mockSonner() builder.
- Toolbar: add tests for the store-driven undo/redo disabled state and the
  unsaved-changes dot — behaviour the full-store mock previously left
  unasserted.

ha-relevant: yes
2026-07-10 02:59:38 +02:00
Pouzor 4124d6c5a4 test: split canvasStore monolith and relocate misnamed util test
- Split the 1430-line canvasStore.test.ts into a canvasStore/ subpackage by
  concern (nodes, containers, sizing, edges, selection, grouping, history,
  clipboard, customStyle, floorMap); each file <350 lines. Test bodies are
  unchanged; local makeNode/makeEdge replaced by the shared factories.
- Move panels/DetailPanel.test.ts -> utils/serviceUrl.test.ts: it tested
  getServiceUrl, not DetailPanel.

Same 1396 tests, all green.

ha-relevant: yes
2026-07-10 02:48:29 +02:00
Pouzor 64c48de1d1 test: add shared frontend test infra (factories, mocks, render)
Foundation for the frontend test consolidation (mirrors backend #267):
- src/test/factories.ts: canonical makeNode/makeEdge/makeNodeData/makeDesign
- src/test/mocks/: reusable mockSonner/mockReactFlow/makeUseCanvasStore builders
  consumed via async vi.mock dynamic-import to survive hoisting
- src/test/render.tsx: renderWithProviders wrapping Tooltip + ReactFlow providers

Migrate ScanConfigModal sonner mock as canary. No behavior change.

ha-relevant: yes
2026-07-10 02:15:49 +02:00
Pouzor - Rémy Jardient 012ba9cb97 Update README.md 2026-07-10 01:41:51 +02:00
Pouzor - Rémy Jardient 6644a208bb Update README.md 2026-07-10 01:39:42 +02:00
Pouzor - Rémy Jardient 54647413c4 Update README.md 2026-07-10 00:53:16 +02:00
Pouzor - Rémy Jardient 93a50cf144 Merge pull request #266 from nicolabottini/feat/mcp-design-targeting
feat: MCP design/canvas targeting + auto-position + auto-edge-handles
2026-07-10 00:46:53 +02:00
Pouzor b7985306cd test: cover auto-position and auto-edge-handle; fix negative-cell clamp
Add backend API tests for the two issue #265 features that shipped
untested: auto-positioning root nodes into a free grid slot (first at
origin, collision avoidance, child origin default, explicit coords and
explicit zero preserved) and auto-assigning edge handles from absolute
canvas Y (source above/below/equal target, partial handle fill, child
abs-Y resolved through parent).

Drop the max(0, round()) clamp in _find_free_position: it folded
negative-positioned nodes onto cell (0,0), falsely blocking or freeing
the origin slot. Negative nodes now keep their true cells and never
intersect the positive search space.
2026-07-10 00:16:32 +02:00
Nicola Bottini 27e18f1c96 feat: auto-position nodes and auto-assign edge handles on create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 00:02:11 +02:00
Nicola Bottini 2d00be71bb feat: add create_design tool so the MCP can create canvases
Wrap POST /api/v1/designs so AI clients can create a new design (canvas) and get its id back for use as design_id. Completes the create-and-target canvas workflow alongside list_designs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:58:28 +02:00
Nicola Bottini 6e3d45fddc feat: let the MCP target a specific design/canvas
Add an optional design_id argument to create_node, create_edge and get_canvas so AI clients can read and populate a canvas other than the first design. Add a list_designs tool wrapping GET /api/v1/designs so those IDs are discoverable.

Backward compatible: omitting design_id preserves the existing first-design fallback in the backend (nodes.py / edges.py / canvas.py).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 23:58:28 +02:00
Pouzor - Rémy Jardient fd3a3f1da6 Merge pull request #267 from Pouzor/refactor/unify-test-auth-fixture
test: refactor backend test suite for quality and consolidation
2026-07-09 23:39:27 +02:00
Pouzor 9205f9d36e test: close coverage gaps hidden by seam mocking
Audit of the heavy-mock suites (scanner/status_checker/scheduler)
confirmed the mocking is sound layered seam-mocking, not brittle
internal patching. It did hide real gaps where a primitive was always
mocked and never exercised:

- status_checker._http_get: add status-code interpretation tests
  (2xx/4xx online, 5xx offline) + service-check exception -> offline.
  Module coverage 95% -> 100%.
- scheduler.reschedule_status_checks / reschedule_service_checks were
  wholly untested; add validation + guard tests. Add _run_proxmox_sync
  happy-path and error-swallow tests. Module coverage 76% -> 91%.

639 passed.

ha-relevant: no
2026-07-09 22:14:36 +02:00
Pouzor 991169ba5a test: consolidate migration tests into a migrations/ subpackage
Group the three upgrade-path suites (legacy->designs, hardware->
properties, pre-migration DB backup) under tests/migrations/ with
clearer names. Pure relocation via git mv; each keeps its own
self-contained fixtures. No behavior change; 627 passed.

ha-relevant: no
2026-07-09 21:51:48 +02:00
Pouzor b513fa6f4e test: split monolithic test_scan into a scan/ subpackage
Break the 1897-line test_scan.py into topic modules under tests/scan/
(routes, approve, properties, run) sharing fixtures via a package
conftest.py and pure builders via helpers.py. Same 80 tests, no
behavior change; full suite 627 passed.

ha-relevant: no
2026-07-09 21:41:49 +02:00
Pouzor 3384a05932 test: unify auth headers into a single conftest fixture
Replace 9 duplicated per-file 'headers' fixtures and the awkward
'auth_headers' coroutine factory with one canonical 'headers' fixture
in conftest.py. Migrate liveview/media tests off the await-based
factory. No behavior change; 627 passed.

ha-relevant: no
2026-07-09 21:26:45 +02:00
Pouzor - Rémy Jardient 9051f6ca3e Merge pull request #264 from Pouzor/docs/changelog
docs: add CHANGELOG with full release history
2026-07-09 20:39:47 +02:00
Pouzor d9c6a4ea31 docs: add CHANGELOG with all release history
Adds CHANGELOG.md covering every GitHub release from v1.0.0 to v2.6.1,
grouped by Added/Changed/Fixed/Security in Keep a Changelog style.

ha-relevant: no
2026-07-09 20:35:02 +02:00
Pouzor - Rémy Jardient 160690bad8 Merge pull request #263 from Pouzor/feat/design-url-sync
feat: sync active design to URL for refresh/share
2026-07-09 17:09:27 +02:00
Pouzor 1aecb0a4d9 feat: sync active design to URL for refresh/share
Switching designs now reflects the active design id in the URL
(?design=<id>). A page refresh or shared link reopens that design;
an absent or unknown id falls back to the default design.

ha-relevant: no
2026-07-09 16:29:04 +02:00
Pouzor - Rémy Jardient 15b000d555 Merge pull request #262 from Pouzor/fix/258-match-device-by-ip-token-and-mac
fix: match scanned devices to canvas nodes by ip-token and mac (#258)
2026-07-09 15:12:13 +02:00
Pouzor 2d3b646e45 fix: match scanned devices to canvas nodes by ip-token and mac (#258)
Node.ip stores several comma-separated addresses once a user adds an
IPv6 (e.g. "fe80::1, 192.168.1.5"). All placement/inventory matching did
exact string equality on that field, so a device scanned as the plain
IPv4 looked absent from the canvas: canvas_count stayed 0, the "In N
canvas" badge and hide-on-canvas button vanished, and bulk-approve
re-placed a duplicate node.

Match per-address instead, and add MAC (a stable identifier immune to IP
edits) as a cumulative match key alongside ieee/ip:
- _canvas_correlation indexes ip per token + by_mac
- bulk_approve skip detection tokenizes ip + tracks mac
- find_duplicate_node narrows with Node.ip.contains then confirms per
  token in Python (guards the 10.0.0.4 / 10.0.0.40 substring false match)

ha-relevant: maybe
2026-07-09 14:41:53 +02:00
Pouzor - Rémy Jardient 2bebe8f42d Merge pull request #261 from Pouzor/feat/single-approve-duplicate-prompt
feat: prompt on duplicate device instead of silently blocking/merging approve
2026-07-09 13:54:56 +02:00
Pouzor 0792105b96 feat: prompt on duplicate device instead of silently blocking/merging approve
Single-device approve now guards duplicates per-design the same way bulk
approve does, and asks the user instead of failing or silently merging:

- create_node and approve_device reject a same-design duplicate (ieee, ip
  or mac) with 409 + the existing node; a force flag creates it anyway.
- Frontend shows a confirm dialog: go to existing node, add duplicate
  anyway, or cancel.
- approve_device no longer rejects a device already on another canvas
  (status is global, canvas membership is per-design) — it can be placed
  on a new design, matching bulk approve.
- IEEE (Zigbee/Z-Wave) devices now use the same prompt as ip/mac instead
  of auto-merging into the existing node.
- bulk approve reports which devices it skipped as duplicates.

Closes #260

ha-relevant: maybe
2026-07-09 13:05:19 +02:00
Pouzor - Rémy Jardient 8ebc716de8 Merge pull request #259 from Pouzor/feat/proxmox-resync-and-inventory-fix
feat: manual Proxmox re-sync + env-only connection config + inventory approve fix
2026-07-07 23:21:22 +02:00
Pouzor b09ebb5cd2 fix: keep approved devices in inventory after bulk approve
Bulk/single approve optimistically stripped approved rows from the local list,
but scanApi.pending() still returns them (on-canvas, with an 'In N canvas'
badge). The list went empty until the modal was reopened. Reload after approve
instead of stripping, so approved rows stay visible with a fresh canvas_count.

ha-relevant: yes
2026-07-07 21:23:28 +02:00
Pouzor 9437a74147 feat: manual Proxmox re-sync button + make connection config env-only
Add a 'Re-sync now' button to the Proxmox auto-sync settings section that
triggers an immediate inventory import (POST /proxmox/sync-now) using the
server env config — the manual counterpart to scheduled auto-sync.

Also fix a dual-source-of-truth bug: Proxmox connection config (host, port,
token, verify_tls) is now env-only and never persisted to scan_config.json.
Previously save_overrides() dumped host/port/verify alongside scan settings,
so saving an unrelated setting wrote an empty proxmox_host that load_overrides
then clobbered PROXMOX_HOST with on every boot. Only the auto-sync activation
(sync_enabled + sync_interval) stays user-editable and persisted.

ha-relevant: no
2026-07-07 21:20:13 +02:00
Pouzor - Rémy Jardient 4206d50c70 Merge pull request #257 from Pouzor/feat/canvas-copy-from-existing
feat: create a new canvas by copying an existing one
2026-07-07 16:23:51 +02:00
Pouzor 0b4bd5680d feat: create a new canvas by copying an existing one
Add a 'Copy from existing' option to the New Canvas modal. It lists every
canvas with node/group/text counts; picking one deep-copies its nodes, edges,
parent/child links and canvas state (viewport, custom style, floor plan) into
a fresh design.

Backend: POST /designs/{source_id}/copy remaps node ids, re-points edges and
parent links, and clones canvas state; GET /designs now returns per-design
counts for the picker. Standalone mode clones the localStorage canvas.

Closes #216

ha-relevant: maybe
2026-07-07 15:53:42 +02:00
Pouzor - Rémy Jardient 6b591cdd88 Merge pull request #256 from Pouzor/fix/media-path-injection
fix: harden media path handling against path injection
2026-07-07 14:55:27 +02:00
Pouzor 7db57bba3b fix: resolve media file by directory listing to kill path-injection taint
Match the validated filename against iterdir() entries with == instead of
building a path from the user string, so no tainted value ever reaches a
filesystem sink. Satisfies CodeQL py/path-injection.

ha-relevant: no
2026-07-07 14:35:44 +02:00
Pouzor b907c4b05e fix: use re.fullmatch for media filename allowlist so CodeQL recognizes barrier
ha-relevant: no
2026-07-07 14:24:19 +02:00
Pouzor 2d376c2bed fix: harden media path handling against path injection (CodeQL py/path-injection)
Resolve media filenames through a shared _resolve_media_path() barrier that
confirms the resolved path sits directly under the resolved media dir, so
CodeQL can trace the sanitization the regex already guaranteed.

ha-relevant: no
2026-07-07 11:50:12 +02:00
Pouzor - Rémy Jardient 738595be5a Merge pull request #255 from Pouzor/fix/yaml-export-connection-points
fix: preserve edge connection points in YAML export/import (#208)
2026-07-07 11:38:34 +02:00
Pouzor 7bc3e5d8a0 fix: preserve edge connection points in YAML export/import (#208)
YAML round-trip dropped every edge's handles: export never wrote them
and import hardcoded sourceHandle='bottom'/targetHandle='top-t'. After
import all connections collapsed onto slot 0 ("converge at a single
point"). Per-side handle counts were dropped too, so the extra bottom
slots did not even exist to attach to.

- yaml types: add sourceHandle/targetHandle to connections and
  top/bottom/left/rightHandles to nodes
- export: write each edge's real handles + per-side counts (only above
  the side default); orient parent-edge handles parent->child
- import: restore handle counts onto node data and use the stored
  handles, falling back to the legacy defaults for pre-existing YAML

Positions remain dagre-managed (unchanged); this restores connection
points only.

ha-relevant: yes
2026-07-07 11:16:29 +02:00
Pouzor - Rémy Jardient 65a1f49f93 Merge pull request #254 from Pouzor/fix/mesh-edges-second-canvas
fix: keep mesh/cluster links so edges resolve onto a second canvas
2026-07-07 10:45:21 +02:00
Pouzor 5b5eabf5db fix: keep mesh/cluster links so edges resolve onto a second canvas
Approving the same zigbee/zwave/proxmox devices onto a second design
placed the nodes but drew no edges. _resolve_pending_links_for_ieee
deleted each pending_device_link after materializing its edge, so the
first approve consumed the whole topology and later approves had nothing
to resolve.

Links are topology, not one-shot: every importer wipes+reinserts its
link set on each import, so they can safely persist across approvals.

- keep the link rows (drop both db.delete(link) calls)
- scope resolution to the target design (Node.ieee_address + design_id)
  so a re-approve links that canvas's nodes, not another's
- thread design_id through all three approve call sites

Existing links deleted by the old code do not come back on their own;
a re-import repopulates them.

ha-relevant: maybe
2026-07-07 10:32:47 +02:00
Pouzor - Rémy Jardient 601f731135 Merge pull request #253 from Pouzor/feat/proxmox-import
feat: import hosts/VMs/LXC from Proxmox VE with optional auto-sync
2026-07-07 09:48:05 +02:00
Pouzor 3d25fcaae2 update doc 2026-07-07 00:53:06 +02:00
Pouzor adf82f8f01 feat: merge IP-scanned and Proxmox-imported devices by MAC
Reconcile the same physical device discovered by both the nmap IP scan and
the Proxmox importer into a single inventory row, keyed on MAC. Previously
each path only deduped by IP, and the importer captured no MAC (and no IP for
stopped guests), so most guests double-listed.

Backend:
- mac_utils.normalize_mac: canonical MAC (lowercase, ':'-separated), the
  cross-source join key. Normalized on write and on compare.
- proxmox_service: capture the guest NIC MAC agent-free from the net0 config
  (qemu virtio=<MAC>, lxc hwaddr=<MAC>); works for stopped guests. Resolver
  now returns (ip, mac).
- proxmox persist: match existing Node/PendingDevice by ieee OR ip OR MAC;
  fill mac, keep the vm/lxc type, union sources.
- scanner persist: match PendingDevice by ip OR MAC; fill the IP a Proxmox
  import lacked, keep a pve row's type, union the scan source. Stamp query
  matches raw + normalized MAC (legacy-safe).
- Multi-source tags: new PendingDevice.discovery_sources JSON column so a
  merged device shows under both the IP and Proxmox filters. Idempotent
  migration backfills from discovery_source (legacy NULL-scalar rows with an
  IP become ["arp"]). _sources_after_merge preserves a scanned row's IP origin
  through the merge without tagging a pure Proxmox guest.
- Import now broadcasts a scan update on completion so an open inventory
  reloads without a manual refresh.

Frontend:
- pendingSources: sourceBuckets/orderedSources map discovery_sources to filter
  buckets; a device with ["arp","proxmox"] matches both filters and renders
  both badges. PendingDevicesModal filter + badges use them.

Tests: MAC normalization, config MAC capture, cross-source merge both
directions, legacy-row IP-tag preservation, no-false-IP-tag guard, refresh
broadcast, and the frontend bucket mapping.

ha-relevant: maybe
2026-07-06 20:11:03 +02:00
Pouzor 05ef746f22 fix: cluster edges render on left/right handles from approve flow
Cluster edges created via the pending -> approve path rendered on the top
handle instead of left/right, because the edge and its endpoints lost their
handle information on the way to the canvas.

- Approve resolver (scan.py) now returns each edge's type + source/target
  handle. Handle IDs are the bare slot-0 side names ('right'/'left'), the
  canonical stored form React Flow resolves to the correct side; a '-t' target
  id fails to resolve and falls back to the top handle.
- Frontend injectAutoEdges no longer hardcodes iot/bottom/top-t. It injects
  each edge with its real type + handles and bumps the referenced nodes'
  left/right handle counts (which default to 0) so the cluster endpoints exist.
  Logic extracted to a pure, tested util (applyAutoEdges).
- clusterEdges direct-import path uses the bare 'left' target to match.

Tests: new autoEdges unit tests; updated backend handle assertions.

ha-relevant: maybe
2026-07-06 11:13:32 +02:00
Pouzor 9670d0a86a feat: proxmox import diagnostics, node style fix, and cluster edges
Scan History:
- Proxmox is now a first-class scan kind (badge, filter chip, Server icon,
  completion toast) instead of being mislabeled as an IP scan.
- A done run carrying a non-fatal advisory renders amber (info) with a
  warning toast, distinct from red failures.

Import diagnostics:
- test-connection probes /access/permissions and warns when the API token
  has no ACL (VMs/LXC would be invisible) — points at the PVEAuditor grant.
- import surfaces an advisory when hosts import but no guests are visible
  (privilege-separated token whose rights are the intersection with the user),
  rather than a silent "done".

Node style:
- Proxmox container mode is now opt-in (container_mode === true), matching the
  rest of the codebase (App.tsx nesting logic). Imported proxmox nodes leave
  the flag unset and render like a manually-created node instead of an empty
  group container.

Cluster edges:
- Hosts from one import are chained with 'cluster' edges via left/right handles,
  distinct from the vertical host->guest 'virtual' edges. Wired for both the
  direct "Add to Canvas" path and the pending -> approve path (host<->host
  proxmox_cluster links, resolved to cluster edges on approve; cluster hosts
  get left/right handles).

Tests added on both sides.

ha-relevant: maybe
2026-07-06 10:26:52 +02:00
Pouzor abefc42fdf fix: tolerate legacy NULL properties on pending devices
The pending_devices.properties column is added by an idempotent migration, so
existing rows have properties = NULL. PendingDeviceResponse typed it as a list,
so GET /scan/pending 500'd on any pre-existing device.

- Coerce NULL/non-list properties to [] in PendingDeviceResponse.
- Backfill existing NULL rows to '[]' in init_db migrations.
- Regression test: /scan/pending returns 200 with a legacy NULL-properties row.

ha-relevant: maybe
2026-07-06 00:01:27 +02:00
Pouzor ab36ba6f81 feat: import hosts/VMs/LXC from Proxmox VE with optional auto-sync
Add a Proxmox VE importer that reads the /api2/json REST API with a read-only
API token and drops hosts (proxmox), VMs (vm) and LXC containers (lxc) onto the
canvas as typed nodes with run state and hardware specs (vCPU/RAM/disk).

- Backend: proxmox_service (httpx) + proxmox routes (test-connection, import,
  import-pending, config). Two-tier dedupe — merge onto an existing scanned node
  by IP, else synthetic pve-{host}-{vmid} identity. Update-in-place, never
  deletes. Host->guest rendered as a 'virtual' edge via the pending-link flow.
- Security: token is env-only (PROXMOX_TOKEN_*), never written to disk by the
  app, never returned by any endpoint; errors are credential-sanitized.
- Auto-sync: optional scheduled re-import into pending (APScheduler job).
- PendingDevice.properties carries specs through approve (+ migration).
- Frontend: ProxmoxImportModal, sidebar entry, pending inventory source filter,
  Settings auto-sync section, proxmoxApi client.
- Docs: docs/proxmox-import.md, README + FEATURES sections, .env.example keys.
- Tests: backend service/router/scheduler, frontend modal/client/pending.

ha-relevant: maybe
2026-07-05 18:58:12 +02:00
Pouzor - Rémy Jardient 1d40d70150 Merge pull request #252 from Pouzor/feat/configurable-edge-line-style
feat: configurable edge line style + width per type and per edge
2026-07-05 15:19:54 +02:00
Pouzor 485d2f2b04 feat: configurable edge line style + width per type and per edge
Edge render (solid/dashed/dotted) and stroke width were hardcoded per
edge type. Expose both as user settings.

- Custom Style modal (Edges): line-style buttons, 1-4x width slider,
  live preview; left-list swatch renders the actual line.
- Per-edge EdgeModal: same controls; line style follows the type preset
  live until overridden.
- Renderer applies line_style/width_mult over BASE_STYLES (width scales
  markers + animation overlays); unset keeps the type default look.
- Persist line_style/width_mult through serializer, canvas save, and the
  edges API (new nullable columns, idempotent migration).

ha-relevant: yes
2026-07-05 14:11:01 +02:00
Pouzor - Rémy Jardient 40ec26ab7e Merge pull request #250 from Pouzor/feat/edge-arrowheads
feat: edge endpoint marker shapes + fix parallel edges not rendering
2026-07-05 11:59:45 +02:00
Pouzor c95d104245 feat: selectable marker shapes per edge endpoint
Replace the on/off arrowhead toggle with a per-end shape picker. Each end
(start/end) independently selects: none, arrow, arrow-open, circle, diamond,
or square. Markers still recolor live from the resolved stroke color.

Frontend:
- MarkerShape type + edgeMarkers util (normalizeMarker, MARKER_GEOMETRY);
  legacy boolean coerces to 'arrow' on read.
- Per-shape <marker> inner geometry; symmetric shapes use fixed orient.
- MarkerShapePicker reused in EdgeModal and CustomStyleModal.
- Serializer normalizes to shape strings.

Backend:
- Edge marker columns Boolean -> String (default 'none'); TEXT migration.
- normalize_marker() + validators coerce legacy bool / unknown values.

ha-relevant: yes
2026-07-05 11:30:49 +02:00
Pouzor 1cf525844b feat: arrowhead endpoints for edges + fix parallel edges not rendering
Add optional filled-triangle arrowheads at either end of an edge,
independently toggleable per edge (EdgeModal) and as per-edge-type
defaults (CustomStyleModal). Arrowheads are custom inline <marker> defs
filled with the live stroke colour so they recolour reactively with
custom_color / vlan / selected state. Persisted frontend (serializer)
and backend (edge columns + schemas + runtime migration).

Also fix two dedupe layers that silently dropped legitimate parallel
links between the same two devices:
- store: React Flow addEdge() connectionExists dropped a second edge
  with matching source+target when handles were null/equal. Build the
  edge with a unique id and append directly.
- render: rewireEdgesForCollapse deduped ALL edges by src->tgt key even
  when nothing was collapsed, filtering real parallel edges out of the
  visible set. Restrict the anti-mesh dedupe to rewired collapse stubs.

Tests: marker render, per-edge/per-type UI, store apply, serializer
round-trip, backend edge/canvas persistence, parallel-edge regressions.

ha-relevant: yes
2026-07-05 01:16:26 +02:00
Pouzor - Rémy Jardient ae2d3e1eab Merge pull request #249 from Pouzor/feat/customizable-connection-points-per-side
feat: customizable connection points per side (#243)
2026-07-04 15:32:05 +02:00
Pouzor e8530ad3db feat: shortcut from node Appearance to canvas-wide type style
Adds a link under the colour swatches in the add/edit node modal that opens the
Custom Style editor with the node's type preselected, so a user can edit the
style for every node of that type without navigating Style -> Customize -> pick
type manually.

- CustomStyleModal: new optional `initialNodeType` prop; when set, the modal
  opens straight into that node type's editor instead of the empty placeholder.
- NodeModal: new optional `onEditTypeStyle(type)` prop renders the shortcut link
  (hidden for group/groupRect and when no handler is wired).
- App: `styleEditorType` state; both Add and Edit NodeModals forward the handler,
  and a standalone CustomStyleModal instance opens preselected (stacked over the
  node modal so in-progress node edits are preserved).

ha-relevant: yes
2026-07-04 15:14:33 +02:00
Pouzor e39ab7d530 fix: restore connection-handle magnet area (unclip node root)
The overflow-hidden added to the BaseNode root (to tame the oversized 16px
NodeResizer handles) also clipped the connection handles, which sit centred on
the node edge with half their box outside it. CSS overflow:hidden disables
pointer events in the clipped region, so the outer half of every handle — the
side an edge approaches from — became non-interactive, halving the magnet area.

- Remove overflow-hidden from the BaseNode root so handles stay fully grabbable.
- Shrink the NodeResizer handles 16px -> 8px (rounded): the reason the clip was
  added in the first place, so the resizer no longer looks oversized when zoomed
  out without relying on clipping.
- Enlarge the invisible target (magnet) hit area 12px -> 20px.
- Set connectionRadius={30} on ReactFlow for stronger distance-based snapping.

ha-relevant: yes
2026-07-04 12:59:08 +02:00
Pouzor 738eac3ebb fix: restore node overflow-hidden (oversized resize handles)
Removing overflow-hidden from the BaseNode root un-clipped the NodeResizer
handles, making them huge (especially when zoomed out). Clipping was never what
made left/right connection points fail — that was backend persistence (fixed
separately) — and top/bottom handles have always worked while clipped. Restore
overflow-hidden so the resizer looks correct; side handles still work.

ha-relevant: yes
2026-07-04 01:47:16 +02:00
Pouzor 1479c777d0 fix: replace Proxmox cluster handles with configurable side points
The Proxmox node had two always-on cluster-left/cluster-right handles that
rendered independently of the new per-side connection-point config — so a
configured side stacked on top of the forced handle (e.g. 0 config still showed
1; 2 config left the forced one in the middle).

- ProxmoxGroupNode: remove the always-on cluster handles in both container and
  non-container modes; cluster links now use the normal per-side points
- add migrateClusterHandles(nodes, edges): on load, remap any edge still bound
  to cluster-left/right onto the left/right slot-0 point and set that node's
  side count to at least 1, so existing links survive (edge 'cluster' type/style
  kept). Applied on API load, standalone load, LiveView, and YAML import
- App: new cluster links are made by choosing the Cluster edge type in the edge
  modal (drop the cluster-handle auto-default)

ha-relevant: yes
2026-07-04 01:35:28 +02:00
Pouzor 6302c43e06 fix: persist top/left/right connection-point counts (issue #243)
The backend only stored bottom_handles, so top/left/right_handles were dropped
on canvas save and reset to defaults on reload — a left/right snappoint would
vanish after reload.

- models.py: add top_handles (default 1), left_handles (0), right_handles (0)
- database.py: ALTER TABLE migrations for the three columns
- schemas: add fields to NodeBase, NodeUpdate, and the canvas-save node schema
- tests: save+reload round-trip and default-fallback coverage

ha-relevant: no
2026-07-04 00:04:24 +02:00
Pouzor 63e664efdd feat: widen node modal, two-column layout, unclip side handles
- NodeModal: widen to max-w-3xl; split into Information (left) and Design
  (right) columns with headers; Notes moved to left as a resizable textarea
- Connection Points: spatial cross of -/N/+ steppers around a node preview
  (replaces the four stacked sliders)
- BaseNode: drop overflow-hidden from the node root so left/right handles are
  no longer clipped to an ungrabbable sliver (inner sections keep their own
  clipping; rounded corners preserved)

ha-relevant: yes
2026-07-04 00:03:09 +02:00
Pouzor b708a08fd0 feat: customizable connection points per side (issue #243)
Generalize the bottom-only handle machinery to all four sides. Top/Bottom
keep their single-handle default; Left/Right are opt-in (default 0) so
existing diagrams render unchanged. Handle IDs stay keyed off the bare side
name at slot 0, keeping saved edges valid.

- handleUtils: side-generic API (handleId, handlePositions, clampHandles,
  sideDefault, removedHandleIds, sideHandleCount); legacy bottom fns kept as
  delegating aliases
- BaseNode + ProxmoxGroupNode render all sides via new shared SideHandles
- updateNode remaps edges on any side's shrink (fallback to slot-0 / bottom)
- serializer round-trips top/left/right_handles
- NodeModal: spatial cross of -/N/+ steppers around a node preview, replacing
  the four stacked sliders; seeds per-type defaults
- CustomStyleModal: per-type default connection points per side

ha-relevant: yes
2026-07-03 21:25:14 +02:00
Pouzor - Rémy Jardient c9a35b730d Merge pull request #248 from Pouzor/docs/features-md
docs: add user-facing FEATURES.md
2026-07-03 14:51:24 +02:00
Pouzor d573cffe0e docs: link README to FEATURES.md before install section 2026-07-03 14:44:37 +02:00
Pouzor b59476f2f9 docs: add user-facing FEATURES.md
Feature guide with table of contents: one line per feature plus how to
enable and use it. Covers zones, groups, multiple canvases, style,
floor plan, scanner, zigbee/zwave import, device inventory, live status,
export, live view, gethomepage, MCP, settings.

Untrack FEATURES.md (was ignored as internal meta) now that it ships as
public docs.

ha-relevant: no
2026-07-03 14:30:48 +02:00
Pouzor - Rémy Jardient 9b40ba0b7c Merge pull request #247 from Pouzor/fix/mesh-import-duplicate-nodes
fix: mesh (zigbee/zwave) import — duplicate-node crash + coordinator to pending
2026-07-03 12:16:47 +02:00
Pouzor 6f82c15c69 fix: backfill inventory row for on-canvas mesh devices missing one
A device already on a canvas (Node exists) but with no pending_devices row
never showed in the discovery inventory — the inventory lists pending_devices,
not nodes. This stranded legacy auto-placed coordinators: on a canvas yet
invisible in the inventory list.

Both mesh imports now, in the already-approved-node branch, ensure an
inventory row exists: create one as status="approved" when missing, or
refresh its metadata (preserving status) when present. New/unplaced devices
still land as status="pending"; hidden rows stay hidden.

Tests updated: approved-node path now backfills an approved inventory row
(zigbee + zwave), plus a regression that a hidden inventory row is not
revived.

ha-relevant: yes
2026-07-03 12:08:53 +02:00
Pouzor 1de6e91ba3 feat: colour device-inventory role badge by node-type accent
The role/type badge on each inventory tile was always flat grey
(bg-[#21262d] text-muted-foreground), which read as disabled and gave no
visual cue about the device kind. Colour it with the same per-type accent
the node uses on the canvas — resolved through the active theme / style
section via resolveNodeColors — so a zigbee_coordinator, zwave_router, etc.
gets its style-section colour (translucent background + solid text).

Applies to every source (IP, zigbee, zwave) since all node types carry an
accent in the theme.

Test: role badge renders with the node-type accent colour, not the grey
muted class.

ha-relevant: yes
2026-07-03 11:13:18 +02:00
Pouzor 3f6e9b00f7 fix: send zigbee/zwave coordinator to pending inventory, not auto-canvas
The coordinator was special-cased in both mesh imports to auto-create a
canvas Node, so it never appeared in the pending inventory and users could
not approve/hide/type it like every other device.

Remove the auto-placement: the coordinator now flows through the shared
pending path (upsert into pending_devices with suggested_type
zigbee_coordinator / zwave_coordinator). An already-approved coordinator
Node still gets its properties refreshed on re-import via the shared
approved-node path. The response's coordinator/coordinator_already_existed
fields are retained (now always unset) for backward-compatible shape; the
frontend already ignored them.

Tests updated: coordinator lands in pending (counts include it), no Node
auto-created, pending metadata carried, approved coordinator refresh + no
pending re-list.

ha-relevant: yes
2026-07-03 02:43:42 +02:00
Pouzor 60383bee64 fix: tolerate same device on multiple canvases in zigbee/zwave import
Zigbee and Z-Wave imports looked up canvas nodes by ieee_address with
scalar_one_or_none(), assuming one node per IEEE globally. A device placed
on two designs (one Node per canvas — a supported feature) made re-import
crash with MultipleResultsFound.

- Both mesh imports now refresh properties on every matching node instead
  of a single row (loop over .scalars().all()).
- approve_device guards against a true duplicate: same IEEE already on the
  SAME design reuses that node instead of inserting a second one.
- New node_dedupe service: loss-free repair keyed on (ieee, design_id).
  Collapses only genuine same-canvas duplicates (merges properties/services/
  missing fields, re-points edges + parent_id, drops self-loops/parallel
  edges). Cross-design placements preserved. Runs at start of both imports
  and bulk-approve. No-op on healthy DBs.
- IP correlation path already handled multiple nodes; left unchanged.

Tests: dedupe unit tests (collapse, cross-design preservation, edge/parent
re-point, idempotent), zigbee + zwave multi-canvas regression, approve
no-dupe guard.

ha-relevant: yes
2026-07-03 01:52:56 +02:00
Pouzor - Rémy Jardient b0cf0deab0 Merge pull request #207 from pranjal-joshi/pranjal/floorplan
feat: floor plan map, LQI edge coloring, zigbee path highlighting
2026-07-03 00:48:39 +02:00
Pouzor bdde03cdfa feat: floor plan selection handles, dbl-click edit, bottom z-index
- Resize handles only render when the plan is selected (click to select,
  click outside to deselect); previously always shown when unlocked.
- Double-clicking an unlocked plan opens the canvas edit modal (via a
  floorMapEditNonce signal the Sidebar watches).
- Floor plan always sits at the bottom of the canvas (z-index -1), behind
  nodes and edges, locked or not.
- Remount the edit modal on every open (key bump) so it re-seeds from the
  current floor plan; fixes Save clobbering a canvas-side resize/move with
  stale modal dimensions.
- Drop the no-op history snapshot on floor-plan edits (floorMap isn't part
  of undo history).
2026-07-03 00:22:53 +02:00
Pouzor 6160090919 revert: drop zigbee LQI edge coloring and path highlighting
Split out of this PR — will land separately with reworked zigbee edge
capture/storage. Keeps only the floor plan + generic media upload work.

- Remove getLqiColor + LQI-derived iot edge stroke (edges/index.tsx)
- Remove zigbee path highlight effect (DetailPanel.tsx)
- Remove highlightedPath/setHighlightedPath store state (canvasStore.ts)
- Delete utils/zigbeePathfinding.ts
- Revert associated test scaffolding and package-lock churn
2026-07-02 17:16:51 +02:00
Pouzor 1ed013bde2 feat: floor plan viewport rendering, per-canvas config, server media upload
- Render floor plan inside React Flow ViewportPortal so it pans/zooms with
  nodes (was screen-fixed, desynced on pan/zoom); zoom-stable resize handles.
- Move floor plan config from the left panel into the canvas (design) edit
  modal; attach per-design and fix cross-design bleed on load.
- Store images via a new generic backend media endpoint (POST/GET/DELETE
  /api/v1/media) on disk under <data_dir>/uploads, not base64 in the canvas.
- Disable floor plans in standalone mode (no backend to upload/serve); drop
  base64 localStorage persistence. See ADR-001 in CLAUDE.md.
- Tests: backend media route, DesignModal floor plan + upload, store floorMap.

ha-relevant: maybe
2026-07-02 16:36:25 +02:00
Pranjal Joshi 046c99e219 fix: null -> undefined for StandaloneCanvas.floorMap 2026-07-02 10:33:57 +05:30
Pranjal Joshi 8821b05b7b feat: floor plan map, LQI edge coloring, zigbee path highlighting, eslint/test fixes, vite audit fix 2026-07-02 02:00:04 +05:30
Pouzor c0c42d5f46 chore: gitignore docs/database-model.md 2026-06-30 14:54:15 +02:00
154 changed files with 14886 additions and 3563 deletions
+38
View File
@@ -1,6 +1,9 @@
# Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
# which sits on the same persistent volume as the DB.
# UPLOAD_DIR=./data/uploads
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
@@ -43,3 +46,38 @@ MCP_SERVICE_KEY=svc_changeme
# the same value in the `X-API-Key` header.
# Generate: python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# HOMEPAGE_API_KEY=
# Proxmox VE import — pull hosts/VMs/LXC from the Proxmox REST API.
# The token is a credential: kept in memory only, never written to disk by the
# app, never returned by any API. Create it under Datacenter → Permissions →
# API Tokens and grant the read-only PVEAuditor role at path "/".
# Token id format: user@realm!tokenname (e.g. root@pam!homelable).
# Required only for auto-sync; one-off imports can pass the token in the dialog.
# PROXMOX_TOKEN_ID=root@pam!homelable
# PROXMOX_TOKEN_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# PROXMOX_HOST=192.168.1.10
# PROXMOX_PORT=8006
# PROXMOX_VERIFY_TLS=true # set false only for self-signed certs
# Zigbee2MQTT auto-sync — pull the mesh from an MQTT broker on a schedule.
# MQTT credentials are secrets: kept in memory only, never written to disk by
# the app, never returned by any API. Manual imports (the Zigbee dialog) are
# unaffected — this block only powers Settings → Zigbee auto-sync and /sync-now.
# Required only for auto-sync; one-off imports pass their config in the dialog.
# ZIGBEE_MQTT_HOST=192.168.1.20
# ZIGBEE_MQTT_PORT=1883
# ZIGBEE_MQTT_USERNAME=mqttuser # optional
# ZIGBEE_MQTT_PASSWORD=mqttpass # optional
# ZIGBEE_BASE_TOPIC=zigbee2mqtt
# ZIGBEE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
# ZIGBEE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
# Z-Wave JS UI (zwavejs2mqtt) auto-sync — same MQTT secret/env rules as Zigbee.
# ZWAVE_MQTT_HOST=192.168.1.20
# ZWAVE_MQTT_PORT=1883
# ZWAVE_MQTT_USERNAME=mqttuser # optional
# ZWAVE_MQTT_PASSWORD=mqttpass # optional
# ZWAVE_PREFIX=zwave
# ZWAVE_GATEWAY_NAME=zwavejs2mqtt
# ZWAVE_MQTT_TLS=false # true for TLS brokers (typically port 8883)
# ZWAVE_MQTT_TLS_INSECURE=false # skip cert verify (self-signed only; requires TLS)
+6 -1
View File
@@ -1,6 +1,5 @@
# Claude / project meta — never commit
CLAUDE.md
FEATURES.md
.claude/
_project_specs/
@@ -51,3 +50,9 @@ htmlcov/
# Docker
.docker/
Ideas.md
# Local dev/test utilities (never commit)
scripts/zwave-mock-gateway.py
# Docs (local only)
docs/database-model.md
+613
View File
@@ -0,0 +1,613 @@
# 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.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 14 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
View File
@@ -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).*
+44 -5
View File
@@ -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 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&amp;utm_medium=badge&amp;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
<p align="center">
<img src="docs/homelable1.png" alt="Homelable canvas overview" width="100%" />
<img src="docs/homelable2.png" alt="Homelable node detail" width="100%" />
<img src="docs/homelable4.png" alt="Homelable edit pannel" width="48%" />
<img width="48%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/06caab68-6637-4dda-ab16-7e83f63d3972" />
<img alt="Homelable Device inventory" src="https://github.com/user-attachments/assets/f3903ac8-354d-4873-81ba-1914971890ed" />
<img width="49.5%" alt="Homelable Custom node" src="https://github.com/user-attachments/assets/813725b1-376b-4bad-bb1f-0985f3bc7546" />
<img width="49.5%" alt="Homelable Zigbee Network" src="https://github.com/user-attachments/assets/35e18d11-8363-498d-ae3d-642685cac76d" />
</p>
---
## Features
From one-click **network scans** and **Proxmox / Zigbee / Z-Wave** imports to **live status monitoring**, floor plans, multi-canvas layouts and an **MCP server** for AI assistants — Homelable maps and watches your whole homelab.
Every feature, with how to turn it on and use it, is described in **[FEATURES.md](./FEATURES.md)**.
---
## Installation
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
@@ -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 lets you share a read-only snapshot of your canvas with anyone on your network — no login required. It is disabled by default.
+1 -1
View File
@@ -1 +1 @@
2.6.1
3.0.0
+101 -3
View File
@@ -1,14 +1,21 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.designs import DesignCreate, DesignResponse, DesignUpdate
from app.schemas.designs import DesignCopy, DesignCreate, DesignResponse, DesignUpdate
router = APIRouter()
# Node.type values that are canvas annotations rather than real devices. Kept in
# sync with the frontend (Sidebar counts, canvasSerializer types).
_GROUP_TYPE = "groupRect"
_TEXT_TYPE = "text"
@router.get("", response_model=list[DesignResponse])
async def list_designs(
@@ -16,7 +23,31 @@ async def list_designs(
_: str = Depends(get_current_user),
) -> list[DesignResponse]:
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)
@@ -35,6 +66,73 @@ async def create_design(
return DesignResponse.model_validate(design)
@router.post("/{source_id}/copy", response_model=DesignResponse, status_code=201)
async def copy_design(
source_id: str,
body: DesignCopy,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> DesignResponse:
"""Create a new design that deep-copies the source's nodes, edges and canvas state."""
source = await db.get(Design, source_id)
if not source:
raise HTTPException(404, "Source design not found")
new_design = Design(name=body.name, icon=body.icon, design_type=source.design_type)
db.add(new_design)
await db.flush()
src_nodes = (await db.execute(select(Node).where(Node.design_id == source_id))).scalars().all()
src_edges = (await db.execute(select(Edge).where(Edge.design_id == source_id))).scalars().all()
# New id per source node so edges and parent links can be re-pointed at the copy.
id_map = {n.id: str(uuid.uuid4()) for n in src_nodes}
# Columns we set explicitly or let the DB default — never copy verbatim.
node_skip = {"id", "design_id", "parent_id", "created_at", "updated_at"}
for n in src_nodes:
cols = {c.name: getattr(n, c.name) for c in Node.__table__.columns if c.name not in node_skip}
db.add(Node(id=id_map[n.id], design_id=new_design.id, parent_id=None, **cols))
await db.flush() # nodes must exist before we wire self-referential parent_id
# Second pass: re-point parent links inside the copy.
for n in src_nodes:
if n.parent_id and n.parent_id in id_map:
child = await db.get(Node, id_map[n.id])
if child:
child.parent_id = id_map[n.parent_id]
edge_skip = {"id", "design_id", "source", "target", "created_at"}
for e in src_edges:
# Skip edges whose endpoints aren't part of this design (dangling FKs).
if e.source not in id_map or e.target not in id_map:
continue
cols = {c.name: getattr(e, c.name) for c in Edge.__table__.columns if c.name not in edge_skip}
db.add(
Edge(
id=str(uuid.uuid4()),
design_id=new_design.id,
source=id_map[e.source],
target=id_map[e.target],
**cols,
)
)
# Copy canvas state (viewport, custom style, and the floor plan carried in viewport).
src_state = await db.get(CanvasState, source_id)
db.add(
CanvasState(
design_id=new_design.id,
viewport=src_state.viewport if src_state else {},
custom_style=src_state.custom_style if src_state else None,
)
)
await db.commit()
await db.refresh(new_design)
return DesignResponse.model_validate(new_design)
@router.put("/{design_id}", response_model=DesignResponse)
async def update_design(
design_id: str,
+58 -1
View File
@@ -4,11 +4,56 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Edge
from app.db.models import Design, Edge, Node
from app.schemas.edges import EdgeCreate, EdgeResponse, EdgeUpdate
router = APIRouter()
# ---------------------------------------------------------------------------
# Auto-handle helpers
# ---------------------------------------------------------------------------
async def _abs_y(db: AsyncSession, node_id: str) -> float | None:
"""Resolve the approximate absolute canvas Y of a node.
Walks up the parent chain (up to 8 levels) and accumulates pos_y offsets so
that children inside containers are compared correctly against top-level nodes.
Returns None when the node is not found.
"""
node = await db.get(Node, node_id)
if node is None:
return None
y = node.pos_y
current = node
for _ in range(8):
if current.parent_id is None:
break
parent = await db.get(Node, current.parent_id)
if parent is None:
break
y += parent.pos_y
current = parent
return y
async def _auto_handles(
db: AsyncSession, source_id: str, target_id: str
) -> tuple[str, str]:
"""Return (source_handle, target_handle) that reflect the upstream/downstream
relationship between two nodes.
- Source above target (lower Y value) → downstream flow: exit bottom, enter top
- Source below target → upstream flow: exit top, enter bottom
- Equal or unknown → default to bottom/top-t (most common topology direction)
"""
src_y = await _abs_y(db, source_id)
tgt_y = await _abs_y(db, target_id)
if src_y is None or tgt_y is None or src_y <= tgt_y:
return "bottom", "top-t"
return "top", "bottom-t"
@router.get("", response_model=list[EdgeResponse])
async def list_edges(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Edge]:
@@ -25,6 +70,18 @@ async def create_edge(body: EdgeCreate, db: AsyncSession = Depends(get_db), _: s
if data.get("design_id") is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
data["design_id"] = first_design.id if first_design else None
# Auto-assign source/target handles when the caller omits them.
# Compares the canvas Y positions of both nodes so that the edge always exits
# the upstream node's bottom and enters the downstream node's top (or vice versa
# for reverse flows), matching the UI convention for top-to-bottom topologies.
if data.get("source_handle") is None or data.get("target_handle") is None:
auto_src, auto_tgt = await _auto_handles(db, data["source"], data["target"])
if data.get("source_handle") is None:
data["source_handle"] = auto_src
if data.get("target_handle") is None:
data["target_handle"] = auto_tgt
edge = Edge(**data)
db.add(edge)
await db.commit()
+100
View File
@@ -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()
+71
View File
@@ -6,9 +6,53 @@ from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
# ---------------------------------------------------------------------------
# Auto-positioning helpers
# ---------------------------------------------------------------------------
# Canvas grid used when placing nodes without explicit coordinates.
# Each slot is wide/tall enough that a normal node card fits without overlap.
_SLOT_W = 200.0
_SLOT_H = 100.0
_MAX_COLS = 7
async def _find_free_position(db: AsyncSession, design_id: str | None) -> tuple[float, float]:
"""Return (x, y) for a new root-level node that doesn't collide with existing ones.
Snaps existing root nodes to a virtual grid and returns the first unoccupied
cell, scanning left-to-right then top-to-bottom.
"""
result = await db.execute(
select(Node.pos_x, Node.pos_y).where(
Node.design_id == design_id,
Node.parent_id.is_(None),
)
)
positions = list(result.all())
if not positions:
return 0.0, 0.0
# Snap each existing node to its true grid cell (negatives kept as-is). The
# search below only visits col >= 0 / row >= 0, so nodes parked in negative
# space never falsely block — or falsely free — a positive slot.
occupied: set[tuple[int, int]] = set()
for (px, py) in positions:
col = round(px / _SLOT_W)
row = round(py / _SLOT_H)
occupied.add((col, row))
for row in range(10_000):
for col in range(_MAX_COLS):
if (col, row) not in occupied:
return col * _SLOT_W, row * _SLOT_H
return 0.0, 0.0 # unreachable in practice
@router.get("", response_model=list[NodeResponse])
async def list_nodes(db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> list[Node]:
@@ -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)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
data = body.model_dump()
# `force` bypasses the duplicate guard below; it is not a Node column.
force = data.pop("force", False)
# Attach to a design so the node lands on a canvas. Clients that don't send a
# design_id (e.g. the MCP write tools) would otherwise create design_id=null
# nodes that exist in the DB but never render in the UI until a container
@@ -26,6 +72,31 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
if data.get("design_id") is None:
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
data["design_id"] = first_design.id if first_design else None
# Reject a silent duplicate: a node with the same ip OR mac already on the
# target design. Scripts/MCP clients get a clear 409 (with the existing id)
# instead of a second card for the same host. Pass force=True to override.
if not force:
dup = await find_duplicate_node(db, data["design_id"], data.get("ip"), data.get("mac"))
if dup is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=dup)
# Auto-position: when pos_x / pos_y are omitted (None), find a free canvas
# slot so the new node doesn't land on top of an existing one.
# Child nodes (parent_id set) use (0, 0) relative to their parent instead.
if data["pos_x"] is None or data["pos_y"] is None:
if data.get("parent_id") is None:
auto_x, auto_y = await _find_free_position(db, data["design_id"])
if data["pos_x"] is None:
data["pos_x"] = auto_x
if data["pos_y"] is None:
data["pos_y"] = auto_y
else:
if data["pos_x"] is None:
data["pos_x"] = 0.0
if data["pos_y"] is None:
data["pos_y"] = 0.0
node = Node(**data)
db.add(node)
await db.commit()
+512
View File
@@ -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
View File
@@ -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.schemas.nodes import NodeCreate
from app.schemas.scan import PendingDeviceResponse, ScanRunResponse
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.scanner import DeepScanOptions, _valid_port_range, request_cancel, run_scan
from app.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
_ZIGBEE_TYPES = {"zigbee_coordinator", "zigbee_router", "zigbee_enddevice"}
_ZWAVE_TYPES = {"zwave_coordinator", "zwave_router", "zwave_enddevice"}
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a Node/device ``ip`` field into individual addresses.
The canvas stores multiple addresses in one comma-separated string (e.g.
``"fe80::1, 192.168.1.5"`` once a user adds an IPv6 address). Matching a
scanned device against that field must compare per-address, not against the
whole string, or the device looks absent from the canvas (issue #258).
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _is_wireless(node_type: str | None) -> bool:
"""Zigbee + Z-Wave mesh devices share online status / no ICMP check."""
return node_type in _ZIGBEE_TYPES or node_type in _ZWAVE_TYPES
@@ -211,12 +226,18 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
async def _canvas_correlation(
db: AsyncSession, devices: list[PendingDevice]
) -> 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
on, plus aggregated timestamps from every matching node — created_at (oldest),
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
(node counts are small for a homelab), so no N+1 per device.
IP matching is per-address: a node's ``ip`` may hold several comma-separated
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
the raw string. MAC is a stable identifier immune to such IP edits, so it is
matched too — cumulatively with ieee/ip (issue #258).
"""
if not devices:
return {}
@@ -224,6 +245,7 @@ async def _canvas_correlation(
await db.execute(
select(
Node.ip,
Node.mac,
Node.ieee_address,
Node.design_id,
Node.created_at,
@@ -233,12 +255,15 @@ async def _canvas_correlation(
).where(Node.design_id.isnot(None))
)
).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_mac: dict[str, list[Any]] = {}
by_ieee: dict[str, list[Any]] = {}
for row in rows:
if row.ip:
by_ip.setdefault(row.ip, []).append(row)
for tok in _ip_tokens(row.ip):
by_ip.setdefault(tok, []).append(row)
if row.mac:
by_mac.setdefault(row.mac, []).append(row)
if row.ieee_address:
by_ieee.setdefault(row.ieee_address, []).append(row)
@@ -247,9 +272,11 @@ async def _canvas_correlation(
matched = []
if d.ieee_address:
matched += by_ieee.get(d.ieee_address, [])
if d.ip:
matched += by_ip.get(d.ip, [])
# De-duplicate nodes matched by both ip and ieee.
if d.mac:
matched += by_mac.get(d.mac, [])
for tok in _ip_tokens(d.ip):
matched += by_ip.get(tok, [])
# De-duplicate nodes matched by more than one identifier.
matched = list({id(m): m for m in matched}.values())
designs = {m.design_id for m in matched}
info[d.id] = {
@@ -308,6 +335,9 @@ async def bulk_approve_devices(
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> dict[str, Any]:
# Repair any legacy same-canvas duplicate nodes before placing more.
await dedupe_nodes_by_ieee(db)
# Target the design the user is on; fall back to the first design.
default_design_id = payload.design_id
if default_design_id is None:
@@ -328,27 +358,57 @@ async def bulk_approve_devices(
devices = result.scalars().all()
# 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 = (
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()
placed_ips = {ip for ip, _ in existing if ip}
placed_ieee = {ieee for _, ieee in existing if ieee}
placed_ips: dict[str, Any] = {
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
}
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
created_nodes: list[Node] = []
approved_devices: list[PendingDevice] = []
skipped_devices: list[dict[str, Any]] = []
for device in devices:
already_here = (
(device.ip is not None and device.ip in placed_ips)
or (device.ieee_address is not None and device.ieee_address in placed_ieee)
)
if already_here:
# Record which identifier collided so the caller can explain each skip
# (and, for existing on-canvas nodes, link to the node already there).
ip_hit = next((t for t in _ip_tokens(device.ip) if t in placed_ips), None)
if ip_hit is not None:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ip or "device",
"match": "ip", "value": ip_hit, "_ref": placed_ips[ip_hit],
})
continue
if device.ieee_address is not None and device.ieee_address in placed_ieee:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.ieee_address or "device",
"match": "ieee", "value": device.ieee_address, "_ref": placed_ieee[device.ieee_address],
})
continue
if device.mac is not None and device.mac in placed_mac:
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.mac or "device",
"match": "mac", "value": device.mac, "_ref": placed_mac[device.mac],
})
continue
device.status = "approved"
node_type = device.suggested_type or "generic"
is_wireless = _is_wireless(node_type)
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
@@ -360,29 +420,43 @@ async def bulk_approve_devices(
ieee_address=device.ieee_address,
properties=_wireless_properties(
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.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_wireless else ("ping" if device.ip else None),
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=default_design_id,
)
db.add(node)
created_nodes.append(node)
approved_devices.append(device)
# Track within this batch so a duplicate selection (same ip/ieee) is not
# placed twice on the same canvas.
if device.ip:
placed_ips.add(device.ip)
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
# not placed twice on the same canvas. Store the Node so a later in-batch
# skip can resolve to its id after flush.
for tok in _ip_tokens(device.ip):
placed_ips[tok] = node
if device.mac:
placed_mac[device.mac] = node
if device.ieee_address:
placed_ieee.add(device.ieee_address)
placed_ieee[device.ieee_address] = node
await db.flush() # populates node.id from Python-side default before reading
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
node_ids = [n.id for n in created_nodes]
approved_device_ids = [d.id for d in approved_devices]
# Resolve each skip's existing-node reference to a concrete id now that any
# in-batch nodes have been flushed, and expose it under a clean key.
for entry in skipped_devices:
ref = entry.pop("_ref")
entry["existing_node_id"] = ref.id if isinstance(ref, Node) else ref
all_edges: list[dict[str, str]] = []
for device in approved_devices:
all_edges.extend(await _resolve_pending_links_for_ieee(db, device.ieee_address))
all_edges.extend(
await _resolve_pending_links_for_ieee(db, device.ieee_address, default_design_id)
)
await db.commit()
return {
@@ -392,6 +466,7 @@ async def bulk_approve_devices(
"edges_created": len(all_edges),
"edges": all_edges,
"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)
if not device:
raise HTTPException(status_code=404, detail="Device not found")
if device.status != "pending":
raise HTTPException(status_code=409, detail="Device already processed")
device.status = "approved"
# A device's status is GLOBAL — it flips to "approved" the moment it lands on
# ANY canvas — but canvas membership is per-design. Approving onto a NEW
# design must therefore work even when the device already sits on another
# one (mirroring bulk_approve_devices, which deliberately does not filter on
# status == "pending"). Same-design duplicates are caught by the per-design
# IEEE/ip/mac guards below, not by this global flag. Only a user-hidden
# device is off-limits here.
if device.status == "hidden":
raise HTTPException(status_code=409, detail="Device is hidden")
wireless = _is_wireless(node_data.type)
# A device already on THIS design (matched by ieee, ip OR mac) is NOT placed
# again automatically: the user might genuinely want a second card, or might
# be re-approving by mistake. Reject with 409 + the existing node so the UI
# can ask — identical handling for IEEE (Zigbee/Z-Wave) and plain IP/ARP
# hosts. force=True (set after the user confirms) skips this and creates it.
# The same device on a *different* design is valid (one Node per canvas), so
# this is scoped to node_design_id.
if not node_data.force:
conflict = await find_duplicate_node(
db, node_design_id,
node_data.ip or device.ip,
node_data.mac or device.mac,
ieee=device.ieee_address,
)
if conflict is not None:
raise HTTPException(status_code=409, detail=conflict)
device.status = "approved"
# Prefer the MAC discovered during the scan (stored on the pending device);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=node_data.label,
type=node_data.type,
@@ -483,16 +584,22 @@ async def approve_device(
ieee_address=device.ieee_address,
properties=_wireless_properties(
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
) if wireless else merge_mac_property(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_target=None if wireless else node_data.check_target,
# Cluster hosts get side handles for their host↔host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=node_design_id,
)
db.add(node)
await db.flush()
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()
return {
@@ -503,15 +610,40 @@ async def approve_device(
}
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee``.
async def _is_proxmox_cluster_member(db: AsyncSession, ieee: str | None) -> bool:
"""True if ``ieee`` participates in a proxmox_cluster link (host↔host).
For each link where the other endpoint already exists as a canvas Node
(matched by ``Node.ieee_address``), create the Edge and drop the link
row. Links where the other endpoint is still pending are kept so they
can resolve when that endpoint is approved later.
Such a host needs one left + one right handle for the cluster edge endpoints
(both default to 0). Checked at approve time, before the link is consumed by
``_resolve_pending_links_for_ieee``.
"""
if not ieee:
return False
found = (
await db.execute(
select(PendingDeviceLink.id)
.where(
PendingDeviceLink.discovery_source == "proxmox_cluster",
(PendingDeviceLink.source_ieee == ieee) | (PendingDeviceLink.target_ieee == ieee),
)
.limit(1)
)
).scalar()
return found is not None
async def _resolve_pending_links_for_ieee(
db: AsyncSession, ieee: str | None, design_id: str | None
) -> list[dict[str, str]]:
"""Materialize edges for any pending_device_links involving ``ieee`` on the
canvas identified by ``design_id``.
For each link where the other endpoint already exists as a Node *on this
design* (matched by ``Node.ieee_address`` + ``Node.design_id``), create the
Edge. Links are **never** deleted here: they describe the discovered mesh /
cluster topology and are wiped+reinserted wholesale on the next import
(zigbee/zwave/proxmox). Keeping them lets the same devices be re-approved
onto a second canvas with their edges intact.
"""
if not ieee:
return []
@@ -526,14 +658,19 @@ async def _resolve_pending_links_for_ieee(
if not links:
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 = {
link.target_ieee if link.source_ieee == ieee else link.source_ieee
for link in links
}
other_ieees.add(ieee)
nodes_q = await db.execute(
select(Node).where(Node.ieee_address.in_(other_ieees))
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}
@@ -564,28 +701,48 @@ async def _resolve_pending_links_for_ieee(
src_id, tgt_id = self_node.id, other_node.id
else:
src_id, tgt_id = other_node.id, self_node.id
# Skip if either direction already exists.
# 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:
await db.delete(link)
continue
# Use the source node's design_id for the edge
edge_design_id = self_node.design_id if self_node else None
if edge_design_id is None:
first = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
edge_design_id = first.id if first else None
# Edge lands on the design we're approving into (the nodes above are
# already scoped to it).
edge_design_id = design_id or (self_node.design_id if self_node else None)
# Edge shape by link source. Handle IDs are the *bare* slot-0 side names
# (the canonical stored form — the save path normalizes '<side>-t' → the
# bare source id, and React Flow resolves the bare id to that side). A
# '-t' target id does not resolve here and RF falls back to the top
# handle, so never emit one.
# proxmox → 'virtual' host→guest, vertical (bottom → top)
# proxmox_cluster → 'cluster' host↔host, horizontal (right → left)
# anything else → 'iot' mesh link, vertical
if link.discovery_source == "proxmox":
edge_type, src_handle, tgt_handle = "virtual", "bottom", "top"
elif link.discovery_source == "proxmox_cluster":
edge_type, src_handle, tgt_handle = "cluster", "right", "left"
else:
edge_type, src_handle, tgt_handle = "iot", "bottom", "top"
edge = Edge(
source=src_id,
target=tgt_id,
type="iot",
source_handle="bottom",
target_handle="top-t",
type=edge_type,
source_handle=src_handle,
target_handle=tgt_handle,
design_id=edge_design_id,
)
db.add(edge)
await db.flush()
existing_pairs.add((src_id, tgt_id))
created.append({"id": edge.id, "source": src_id, "target": tgt_id})
await db.delete(link)
# Return the edge's type + handles so the client injects it faithfully
# (a cluster edge must keep its right→left handles, not the iot default).
created.append({
"id": edge.id,
"source": src_id,
"target": tgt_id,
"type": edge_type,
"source_handle": src_handle,
"target_handle": tgt_handle,
})
return created
+152 -43
View File
@@ -10,19 +10,24 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zigbee_sync, set_zigbee_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zigbee import (
ZigbeeConfig,
ZigbeeCoordinatorOut,
ZigbeeEdgeOut,
ZigbeeImportPendingResponse,
ZigbeeImportRequest,
ZigbeeImportResponse,
ZigbeeNodeOut,
ZigbeeSyncConfig,
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.zigbee_service import (
build_zigbee_properties,
fetch_networkmap,
@@ -98,6 +103,53 @@ async def import_zigbee_to_pending(
return run
def env_import_request() -> ZigbeeImportRequest:
"""Build an import request from the server env config (for auto-sync).
MQTT credentials live in the env only — never in the request body or any
API response. The scheduled auto-sync job and ``/sync-now`` both source
their connection settings here so there is a single source of truth."""
return ZigbeeImportRequest(
mqtt_host=settings.zigbee_mqtt_host,
mqtt_port=settings.zigbee_mqtt_port,
mqtt_username=settings.zigbee_mqtt_username or None,
mqtt_password=settings.zigbee_mqtt_password or None,
base_topic=settings.zigbee_base_topic,
mqtt_tls=settings.zigbee_mqtt_tls,
mqtt_tls_insecure=settings.zigbee_mqtt_tls_insecure,
)
@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_zigbee_now(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Trigger an immediate Zigbee import using the server env config.
Same background flow as ``/import-pending`` but sources the MQTT connection
from ``settings`` (env) rather than the request body — the manual
counterpart to the scheduled auto-sync job. Requires the env host to be set.
"""
if not settings.zigbee_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot sync: no Zigbee MQTT host configured on the server.",
)
payload = env_import_request()
run = ScanRun(
status="running",
kind="zigbee",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zigbee_import, run.id, payload)
return run
async def _background_zigbee_import(run_id: str, payload: ZigbeeImportRequest) -> None:
async with AsyncSessionLocal() as db:
try:
@@ -138,10 +190,12 @@ async def _persist_pending_import(
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.
"""
# Determine target design (use first design as fallback)
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
# the by-IEEE lookups below resolve to a single row.
await dedupe_nodes_by_ieee(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
coordinator_out: ZigbeeCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
@@ -155,49 +209,57 @@ async def _persist_pending_import(
ieee, n.get("vendor"), n.get("model"), n.get("lqi")
)
if n.get("device_type") == "Coordinator":
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
existing_node = existing.scalar_one_or_none()
if existing_node:
# The coordinator is no longer auto-placed on the canvas — it flows to
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# If the device has already been approved as a canvas Node, refresh its
# properties on every canvas it sits on. Still ensure the discovery
# inventory carries a row for it (status="approved") so it shows in the
# inventory list with an "In N canvas" badge — legacy auto-placed
# coordinators never got a pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
coordinator_out = ZigbeeCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
ieee_address=ieee,
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
coordinator_existed = True
continue
label = n.get("friendly_name") or ieee
node = Node(
label=label,
type=n.get("type") or "zigbee_coordinator",
status="online",
check_method="none",
ieee_address=ieee,
services=[],
properties=props,
design_id=default_design_id,
)
db.add(node)
await db.flush()
coordinator_out = ZigbeeCoordinatorOut(
id=node.id, label=label, ieee_address=ieee
)
continue
# If the device has already been approved as a canvas Node, refresh
# its properties and skip creating a pending row (keeps approved
# devices out of pending/hidden modals on re-import).
existing_node_q = await db.execute(
select(Node).where(Node.ieee_address == ieee)
)
existing_node = existing_node_q.scalar_one_or_none()
if existing_node:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zigbee",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status (an approved
# device stays approved; a hidden one stays hidden).
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
result = await db.execute(
@@ -296,3 +358,50 @@ async def test_zigbee_connection(
except Exception:
logger.exception("Unexpected error during connection test")
return ZigbeeTestConnectionResponse(connected=False, message="Unexpected error")
@router.get("/config", response_model=ZigbeeConfig)
async def get_zigbee_config(_: str = Depends(get_current_user)) -> ZigbeeConfig:
"""Return non-secret Zigbee config. Never includes MQTT credentials — only
whether a host is configured on the server for auto-sync."""
return ZigbeeConfig(
mqtt_host=settings.zigbee_mqtt_host,
mqtt_port=settings.zigbee_mqtt_port,
base_topic=settings.zigbee_base_topic,
mqtt_tls=settings.zigbee_mqtt_tls,
sync_enabled=settings.zigbee_sync_enabled,
sync_interval=settings.zigbee_sync_interval,
host_configured=bool(settings.zigbee_mqtt_host),
)
@router.post("/config", response_model=ZigbeeConfig)
async def save_zigbee_config(
payload: ZigbeeSyncConfig,
_: str = Depends(get_current_user),
) -> ZigbeeConfig:
"""Persist the auto-sync activation (enabled + interval) and apply it live.
This is the ONLY Zigbee config the app writes. Connection settings
(host/port/credentials/topic/tls) are env-only and are never accepted or
persisted here — enabling auto-sync requires the MQTT host already set in
the server env, since the scheduled job reads it from there.
"""
if payload.sync_enabled and not settings.zigbee_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot enable auto-sync: no Zigbee MQTT host configured in the server env.",
)
try:
settings.zigbee_sync_enabled = payload.sync_enabled
settings.zigbee_sync_interval = payload.sync_interval
settings.save_overrides()
set_zigbee_sync_enabled(payload.sync_enabled)
if payload.sync_enabled:
reschedule_zigbee_sync(payload.sync_interval)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_zigbee_config()
+153 -40
View File
@@ -10,19 +10,24 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_zwave_sync, set_zwave_sync_enabled
from app.db.database import AsyncSessionLocal, get_db
from app.db.models import Design, Node, PendingDevice, PendingDeviceLink, ScanRun
from app.db.models import Node, PendingDevice, PendingDeviceLink, ScanRun
from app.schemas.scan import ScanRunResponse
from app.schemas.zwave import (
ZwaveConfig,
ZwaveCoordinatorOut,
ZwaveEdgeOut,
ZwaveImportPendingResponse,
ZwaveImportRequest,
ZwaveImportResponse,
ZwaveNodeOut,
ZwaveSyncConfig,
ZwaveTestConnectionRequest,
ZwaveTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.zwave_service import (
build_zwave_properties,
fetch_zwave_network,
@@ -93,6 +98,54 @@ async def import_zwave_to_pending(
return run
def env_import_request() -> ZwaveImportRequest:
"""Build an import request from the server env config (for auto-sync).
MQTT credentials live in the env only — never in the request body or any
API response. The scheduled auto-sync job and ``/sync-now`` both source
their connection settings here so there is a single source of truth."""
return ZwaveImportRequest(
mqtt_host=settings.zwave_mqtt_host,
mqtt_port=settings.zwave_mqtt_port,
mqtt_username=settings.zwave_mqtt_username or None,
mqtt_password=settings.zwave_mqtt_password or None,
prefix=settings.zwave_prefix,
gateway_name=settings.zwave_gateway_name,
mqtt_tls=settings.zwave_mqtt_tls,
mqtt_tls_insecure=settings.zwave_mqtt_tls_insecure,
)
@router.post("/sync-now", response_model=ScanRunResponse)
async def sync_zwave_now(
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> ScanRun:
"""Trigger an immediate Z-Wave import using the server env config.
Same background flow as ``/import-pending`` but sources the MQTT connection
from ``settings`` (env) rather than the request body — the manual
counterpart to the scheduled auto-sync job. Requires the env host to be set.
"""
if not settings.zwave_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot sync: no Z-Wave MQTT host configured on the server.",
)
payload = env_import_request()
run = ScanRun(
status="running",
kind="zwave",
ranges=[f"{payload.mqtt_host}:{payload.mqtt_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
background_tasks.add_task(_background_zwave_import, run.id, payload)
return run
async def _background_zwave_import(run_id: str, payload: ZwaveImportRequest) -> None:
async with AsyncSessionLocal() as db:
try:
@@ -134,9 +187,12 @@ async def _persist_pending_import(
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.
"""
first_design = (await db.execute(select(Design).order_by(Design.created_at).limit(1))).scalar()
default_design_id = first_design.id if first_design else None
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
# the by-IEEE lookups below resolve cleanly.
await dedupe_nodes_by_ieee(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
coordinator_out: ZwaveCoordinatorOut | None = None
coordinator_existed = False
pending_created = 0
@@ -148,47 +204,56 @@ async def _persist_pending_import(
continue
props = build_zwave_properties(ieee, n.get("vendor"), n.get("model"))
if n.get("type") == "zwave_coordinator":
existing = await db.execute(select(Node).where(Node.ieee_address == ieee))
existing_node = existing.scalar_one_or_none()
if existing_node:
# The coordinator is no longer auto-placed on the canvas — it flows to
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# Already approved as a canvas Node → refresh props on every canvas it
# sits on. Still ensure the discovery inventory carries a row for it
# (status="approved") so it shows in the inventory list with an
# "In N canvas" badge — legacy auto-placed coordinators never got a
# pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zwave_properties(
existing_node.properties, props
)
coordinator_out = ZwaveCoordinatorOut(
id=existing_node.id,
label=existing_node.label,
ieee_address=ieee,
inv = (
await db.execute(
select(PendingDevice).where(PendingDevice.ieee_address == ieee)
)
coordinator_existed = True
continue
label = n.get("friendly_name") or ieee
node = Node(
label=label,
type=n.get("type") or "zwave_coordinator",
status="online",
check_method="none",
ieee_address=ieee,
services=[],
properties=props,
design_id=default_design_id,
)
db.add(node)
await db.flush()
coordinator_out = ZwaveCoordinatorOut(
id=node.id, label=label, ieee_address=ieee
)
continue
# Already approved as a canvas Node → refresh props, skip pending row.
existing_node_q = await db.execute(
select(Node).where(Node.ieee_address == ieee)
)
existing_node = existing_node_q.scalar_one_or_none()
if existing_node:
existing_node.properties = merge_zwave_properties(
existing_node.properties, props
)
).scalar_one_or_none()
if inv is None:
db.add(
PendingDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zwave",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status.
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
result = await db.execute(
@@ -282,3 +347,51 @@ async def test_connection_endpoint(
except Exception:
logger.exception("Unexpected error during connection test")
return ZwaveTestConnectionResponse(connected=False, message="Unexpected error")
@router.get("/config", response_model=ZwaveConfig)
async def get_zwave_config(_: str = Depends(get_current_user)) -> ZwaveConfig:
"""Return non-secret Z-Wave config. Never includes MQTT credentials — only
whether a host is configured on the server for auto-sync."""
return ZwaveConfig(
mqtt_host=settings.zwave_mqtt_host,
mqtt_port=settings.zwave_mqtt_port,
prefix=settings.zwave_prefix,
gateway_name=settings.zwave_gateway_name,
mqtt_tls=settings.zwave_mqtt_tls,
sync_enabled=settings.zwave_sync_enabled,
sync_interval=settings.zwave_sync_interval,
host_configured=bool(settings.zwave_mqtt_host),
)
@router.post("/config", response_model=ZwaveConfig)
async def save_zwave_config(
payload: ZwaveSyncConfig,
_: str = Depends(get_current_user),
) -> ZwaveConfig:
"""Persist the auto-sync activation (enabled + interval) and apply it live.
This is the ONLY Z-Wave config the app writes. Connection settings
(host/port/credentials/prefix/gateway/tls) are env-only and are never
accepted or persisted here — enabling auto-sync requires the MQTT host
already set in the server env, since the scheduled job reads it from there.
"""
if payload.sync_enabled and not settings.zwave_mqtt_host:
raise HTTPException(
status_code=400,
detail="Cannot enable auto-sync: no Z-Wave MQTT host configured in the server env.",
)
try:
settings.zwave_sync_enabled = payload.sync_enabled
settings.zwave_sync_interval = payload.sync_interval
settings.save_overrides()
set_zwave_sync_enabled(payload.sync_enabled)
if payload.sync_enabled:
reschedule_zwave_sync(payload.sync_interval)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return await get_zwave_config()
+80
View File
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
secret_key: str # Required — set SECRET_KEY in .env
sqlite_path: str = "./data/homelab.db"
# Uploaded media (floor plans, and future raw image uploads) live on disk,
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
upload_dir: str = ""
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT
@@ -76,9 +80,55 @@ class Settings(BaseSettings):
# Leave empty to keep the feature disabled (default).
homepage_api_key: str = ""
# Proxmox VE import.
# Token = a real credential → env/.env ONLY, never persisted by the app to
# scan_config.json and never returned by the API. token_id is
# 'user@realm!tokenname'; use a read-only PVEAuditor role.
proxmox_token_id: str = ""
proxmox_token_secret: str = ""
# Non-secret connection + auto-sync config (persisted via save_overrides).
proxmox_host: str = ""
proxmox_port: int = 8006
proxmox_verify_tls: bool = True
proxmox_sync_enabled: bool = False
proxmox_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
# Zigbee2MQTT auto-sync import.
# MQTT credentials are secrets → env/.env ONLY, never persisted by the app to
# scan_config.json and never returned by the API. Only the auto-sync
# activation (enabled + interval) is persisted; connection config is env-only.
zigbee_mqtt_host: str = ""
zigbee_mqtt_port: int = 1883
zigbee_mqtt_username: str = ""
zigbee_mqtt_password: str = ""
zigbee_base_topic: str = "zigbee2mqtt"
zigbee_mqtt_tls: bool = False
zigbee_mqtt_tls_insecure: bool = False
zigbee_sync_enabled: bool = False
zigbee_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
# Z-Wave JS UI (zwavejs2mqtt) auto-sync import. Same secret/env rules.
zwave_mqtt_host: str = ""
zwave_mqtt_port: int = 1883
zwave_mqtt_username: str = ""
zwave_mqtt_password: str = ""
zwave_prefix: str = "zwave"
zwave_gateway_name: str = "zwavejs2mqtt"
zwave_mqtt_tls: bool = False
zwave_mqtt_tls_insecure: bool = False
zwave_sync_enabled: bool = False
zwave_sync_interval: int = 3600 # seconds (floor 300 enforced on write)
def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json"
def media_dir(self) -> Path:
"""On-disk folder for uploaded media. Sits on the same persistent
volume as the SQLite DB unless UPLOAD_DIR is set."""
if self.upload_dir:
return Path(self.upload_dir)
return Path(self.sqlite_path).parent / "uploads"
def load_overrides(self) -> None:
"""Load runtime scan config overrides written by the API."""
try:
@@ -97,6 +147,25 @@ class Settings(BaseSettings):
self.scanner_http_probe_enabled = bool(data["scanner_http_probe_enabled"])
if "scanner_http_verify_tls" in data:
self.scanner_http_verify_tls = bool(data["scanner_http_verify_tls"])
# Proxmox auto-sync activation only. Connection config (host, port,
# token, verify_tls) is env-only by design — never read from or
# written to this file. Persisting host here previously created a
# dual source of truth that silently clobbered PROXMOX_HOST.
if "proxmox_sync_enabled" in data:
self.proxmox_sync_enabled = bool(data["proxmox_sync_enabled"])
if "proxmox_sync_interval" in data:
self.proxmox_sync_interval = int(data["proxmox_sync_interval"])
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
# connection config (host, port, credentials, topic, tls) is env-only
# by design — never read from or written to this file.
if "zigbee_sync_enabled" in data:
self.zigbee_sync_enabled = bool(data["zigbee_sync_enabled"])
if "zigbee_sync_interval" in data:
self.zigbee_sync_interval = int(data["zigbee_sync_interval"])
if "zwave_sync_enabled" in data:
self.zwave_sync_enabled = bool(data["zwave_sync_enabled"])
if "zwave_sync_interval" in data:
self.zwave_sync_interval = int(data["zwave_sync_interval"])
except Exception:
pass
@@ -111,6 +180,17 @@ class Settings(BaseSettings):
"scanner_http_ranges": self.scanner_http_ranges,
"scanner_http_probe_enabled": self.scanner_http_probe_enabled,
"scanner_http_verify_tls": self.scanner_http_verify_tls,
# Proxmox: only the auto-sync activation is persisted. Connection
# config (host, port, token, verify_tls) is env-only and must never
# be written to disk — that is the single source of truth.
"proxmox_sync_enabled": self.proxmox_sync_enabled,
"proxmox_sync_interval": self.proxmox_sync_interval,
# Zigbee/Z-Wave: only the auto-sync activation is persisted. MQTT
# connection config (host/port/credentials/topic/tls) is env-only.
"zigbee_sync_enabled": self.zigbee_sync_enabled,
"zigbee_sync_interval": self.zigbee_sync_interval,
"zwave_sync_enabled": self.zwave_sync_enabled,
"zwave_sync_interval": self.zwave_sync_interval,
}))
+211
View File
@@ -1,7 +1,9 @@
"""APScheduler setup for background scan and status check jobs."""
import asyncio
import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
@@ -11,6 +13,10 @@ from app.db.database import AsyncSessionLocal
from app.db.models import Node
from app.services.status_checker import check_node, check_services
if TYPE_CHECKING:
from app.schemas.zigbee import ZigbeeImportRequest
from app.schemas.zwave import ZwaveImportRequest
logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler()
@@ -106,6 +112,97 @@ async def _run_service_checks() -> None:
logger.error("Service checks failed for node %s: %s", node_id, exc)
async def _run_proxmox_sync() -> None:
"""Fetch the Proxmox inventory and upsert it into pending (auto-sync).
Records a ScanRun (kind=proxmox) so the scheduled sync shows in Scan
history, exactly like the manual /sync-now and /import-pending paths.
"""
if not settings.proxmox_sync_enabled:
return
if not (settings.proxmox_host and settings.proxmox_token_id and settings.proxmox_token_secret):
logger.warning("Proxmox auto-sync enabled but host/token not configured — skipping")
return
# Lazy import to avoid a circular import at module load.
from app.api.routes.proxmox import _background_proxmox_import
from app.db.models import ScanRun
async with AsyncSessionLocal() as db:
run = ScanRun(
status="running",
kind="proxmox",
ranges=[f"{settings.proxmox_host}:{settings.proxmox_port}"],
)
db.add(run)
await db.commit()
await db.refresh(run)
run_id = run.id
# Shares the manual-sync flow: fetch + persist + mark the run done/error +
# broadcast the inventory-reload signal.
await _background_proxmox_import(
run_id,
settings.proxmox_host,
settings.proxmox_port,
settings.proxmox_token_id,
settings.proxmox_token_secret,
settings.proxmox_verify_tls,
)
async def _run_mesh_sync(kind: str) -> None:
"""Shared auto-sync for the MQTT mesh imports (Zigbee / Z-Wave).
Records a ScanRun (kind=zigbee|zwave) so the scheduled sync shows in Scan
history, then delegates to the exact same background import the manual
/sync-now and /import-pending paths use. Connection config comes from the
server env only (credentials never leave it).
"""
from app.db.models import ScanRun
payload: ZigbeeImportRequest | ZwaveImportRequest
background: Callable[[str, Any], Awaitable[None]]
if kind == "zigbee":
if not settings.zigbee_sync_enabled:
return
if not settings.zigbee_mqtt_host:
logger.warning("Zigbee auto-sync enabled but MQTT host not configured — skipping")
return
from app.api.routes.zigbee import _background_zigbee_import
from app.api.routes.zigbee import env_import_request as _zigbee_env_request
host, port = settings.zigbee_mqtt_host, settings.zigbee_mqtt_port
payload = _zigbee_env_request()
background = _background_zigbee_import
else:
if not settings.zwave_sync_enabled:
return
if not settings.zwave_mqtt_host:
logger.warning("Z-Wave auto-sync enabled but MQTT host not configured — skipping")
return
from app.api.routes.zwave import _background_zwave_import
from app.api.routes.zwave import env_import_request as _zwave_env_request
host, port = settings.zwave_mqtt_host, settings.zwave_mqtt_port
payload = _zwave_env_request()
background = _background_zwave_import
async with AsyncSessionLocal() as db:
run = ScanRun(status="running", kind=kind, ranges=[f"{host}:{port}"])
db.add(run)
await db.commit()
await db.refresh(run)
run_id = run.id
await background(run_id, payload)
async def _run_zigbee_sync() -> None:
await _run_mesh_sync("zigbee")
async def _run_zwave_sync() -> None:
await _run_mesh_sync("zwave")
def _add_service_check_job() -> None:
scheduler.add_job(
_run_service_checks,
@@ -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:
global scheduler
if scheduler.running:
@@ -135,6 +265,12 @@ def start_scheduler() -> None:
)
if settings.service_check_enabled:
_add_service_check_job()
if settings.proxmox_sync_enabled:
_add_proxmox_sync_job()
if settings.zigbee_sync_enabled:
_add_zigbee_sync_job()
if settings.zwave_sync_enabled:
_add_zwave_sync_job()
scheduler.start()
logger.info("Scheduler started — status checks every %ds", settings.status_checker_interval)
@@ -175,6 +311,81 @@ def set_service_checks_enabled(enabled: bool) -> None:
logger.info("Service checks disabled")
def reschedule_proxmox_sync(interval_seconds: int) -> None:
"""Update the Proxmox auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("proxmox_sync"):
scheduler.reschedule_job("proxmox_sync", trigger="interval", seconds=interval_seconds)
logger.info("Proxmox auto-sync rescheduled to every %ds", interval_seconds)
def set_proxmox_sync_enabled(enabled: bool) -> None:
"""Add or remove the Proxmox auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("proxmox_sync")
if enabled and not job:
_add_proxmox_sync_job()
logger.info("Proxmox auto-sync enabled — every %ds", settings.proxmox_sync_interval)
elif not enabled and job:
scheduler.remove_job("proxmox_sync")
logger.info("Proxmox auto-sync disabled")
def reschedule_zigbee_sync(interval_seconds: int) -> None:
"""Update the Zigbee auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("zigbee_sync"):
scheduler.reschedule_job("zigbee_sync", trigger="interval", seconds=interval_seconds)
logger.info("Zigbee auto-sync rescheduled to every %ds", interval_seconds)
def set_zigbee_sync_enabled(enabled: bool) -> None:
"""Add or remove the Zigbee auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("zigbee_sync")
if enabled and not job:
_add_zigbee_sync_job()
logger.info("Zigbee auto-sync enabled — every %ds", settings.zigbee_sync_interval)
elif not enabled and job:
scheduler.remove_job("zigbee_sync")
logger.info("Zigbee auto-sync disabled")
def reschedule_zwave_sync(interval_seconds: int) -> None:
"""Update the Z-Wave auto-sync interval on the running scheduler (if enabled)."""
if interval_seconds < 300:
raise ValueError(f"interval_seconds must be >= 300, got {interval_seconds}")
if not scheduler.running:
logger.warning("Scheduler not running, skipping reschedule")
return
if scheduler.get_job("zwave_sync"):
scheduler.reschedule_job("zwave_sync", trigger="interval", seconds=interval_seconds)
logger.info("Z-Wave auto-sync rescheduled to every %ds", interval_seconds)
def set_zwave_sync_enabled(enabled: bool) -> None:
"""Add or remove the Z-Wave auto-sync job on the running scheduler."""
if not scheduler.running:
return
job = scheduler.get_job("zwave_sync")
if enabled and not job:
_add_zwave_sync_job()
logger.info("Z-Wave auto-sync enabled — every %ds", settings.zwave_sync_interval)
elif not enabled and job:
scheduler.remove_job("zwave_sync")
logger.info("Z-Wave auto-sync disabled")
def stop_scheduler() -> None:
if scheduler.running:
scheduler.shutdown(wait=False)
+50
View File
@@ -81,6 +81,14 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN target_handle TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN animated BOOLEAN NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_start TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN marker_end TEXT NOT NULL DEFAULT 'none'")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN line_style TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE edges ADD COLUMN width_mult REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN cpu_count INTEGER")
with suppress(OperationalError):
@@ -99,8 +107,18 @@ async def init_db() -> None:
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN height REAL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN bottom_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN top_handles INTEGER NOT NULL DEFAULT 1")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN left_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE nodes ADD COLUMN right_handles INTEGER NOT NULL DEFAULT 0")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_source TEXT")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN properties JSON")
with suppress(OperationalError):
await conn.exec_driver_sql("UPDATE pending_devices SET properties = '[]' WHERE properties IS NULL")
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE scan_runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'ip'")
# --- Zigbee schema migrations (logged variant per CLAUDE.md feedback) ---
@@ -148,6 +166,7 @@ async def init_db() -> None:
"model VARCHAR,"
"vendor VARCHAR,"
"lqi INTEGER,"
"properties JSON,"
"discovered_at DATETIME"
")"
)
@@ -302,6 +321,37 @@ async def init_db() -> None:
with suppress(OperationalError):
sql = "UPDATE edges SET animated = 'none' WHERE animated = '0' OR animated = 0 OR animated IS NULL"
await conn.exec_driver_sql(sql)
# Multi-source discovery tags: a device found by both an IP scan and a
# Proxmox import carries every source. Backfill from the legacy single
# discovery_source so existing rows show under their filter.
with suppress(OperationalError):
await conn.exec_driver_sql("ALTER TABLE pending_devices ADD COLUMN discovery_sources JSON")
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array(discovery_source) "
"WHERE discovery_sources IS NULL AND discovery_source IS NOT NULL"
)
# Legacy IP-scanned rows predating discovery_source have a NULL scalar
# but a real IP — treat them as an ARP scan so they keep the IP tag.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = json_array('arp') "
"WHERE discovery_sources IS NULL AND discovery_source IS NULL AND ip IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET discovery_sources = '[]' WHERE discovery_sources IS NULL"
)
# Canonicalize stored MACs (lowercase, ':' separators) so cross-source
# dedup can match a Proxmox NIC MAC against an ARP-scanned one by equality.
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE pending_devices SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
with suppress(OperationalError):
await conn.exec_driver_sql(
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+17
View File
@@ -59,6 +59,9 @@ class Node(Base):
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
top_handles: Mapped[int] = mapped_column(Integer, default=1)
left_handles: Mapped[int] = mapped_column(Integer, default=0)
right_handles: Mapped[int] = mapped_column(Integer, default=0)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
@@ -82,7 +85,11 @@ class Edge(Base):
speed: Mapped[str | None] = mapped_column(String)
custom_color: Mapped[str | None] = mapped_column(String)
path_style: Mapped[str | None] = mapped_column(String)
line_style: Mapped[str | None] = mapped_column(String)
width_mult: Mapped[float | None] = mapped_column(Float)
animated: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_start: Mapped[str] = mapped_column(String, nullable=False, default='none')
marker_end: Mapped[str] = mapped_column(String, nullable=False, default='none')
source_handle: Mapped[str | None] = mapped_column(String)
target_handle: Mapped[str | None] = mapped_column(String)
waypoints: Mapped[list[dict[str, float]] | None] = mapped_column(JSON, nullable=True)
@@ -112,13 +119,23 @@ class PendingDevice(Base):
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
suggested_type: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="pending")
# Origin/primary source (first discovery): "arp"/"mdns"/"zigbee"/"zwave"/
# "proxmox". Kept for back-compat; `discovery_sources` is the full set.
discovery_source: Mapped[str | None] = mapped_column(String)
# All sources that have observed this device. A device found by both an IP
# scan and a Proxmox import carries e.g. ["arp", "proxmox"] and shows under
# both inventory filters. Source of truth for the frontend source badges.
discovery_sources: Mapped[list[Any]] = mapped_column(JSON, default=list)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True, unique=True)
friendly_name: Mapped[str | None] = mapped_column(String, nullable=True)
device_subtype: Mapped[str | None] = mapped_column(String, nullable=True)
model: Mapped[str | None] = mapped_column(String, nullable=True)
vendor: Mapped[str | None] = mapped_column(String, nullable=True)
lqi: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Display properties carried from discovery (e.g. Proxmox specs: CPU/RAM/Disk,
# VMID). Generic NodeProperty shape {key,value,icon,visible}; merged into the
# Node's properties on approve. Empty for scan/mesh sources that don't set it.
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# Transient (not persisted): populated per-request by the scan routes to report
+17 -1
View File
@@ -7,7 +7,21 @@ from typing import Any
from fastapi import FastAPI
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.core.config import settings
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(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
app.include_router(proxmox.router, prefix="/api/v1/proxmox", tags=["proxmox"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
@app.get("/api/v1/health")
+13 -1
View File
@@ -4,7 +4,7 @@ from pydantic import BaseModel, field_validator
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.schemas.utils import normalize_animated
from app.schemas.utils import normalize_animated, normalize_marker
class NodeSave(BaseModel):
@@ -34,6 +34,9 @@ class NodeSave(BaseModel):
width: float | None = None
height: float | None = None
bottom_handles: int = 1
top_handles: int = 1
left_handles: int = 0
right_handles: int = 0
pos_x: float = 0
pos_y: float = 0
@@ -48,7 +51,11 @@ class EdgeSave(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str = 'none'
marker_start: str = 'none'
marker_end: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -58,6 +65,11 @@ class EdgeSave(BaseModel):
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class CanvasSaveRequest(BaseModel):
nodes: list[NodeSave] = []
+12
View File
@@ -16,6 +16,13 @@ class DesignUpdate(BaseModel):
icon: str | None = None
class DesignCopy(BaseModel):
"""Create a new design by deep-copying an existing one's canvas."""
name: str
icon: str = "dashboard"
class DesignResponse(BaseModel):
id: str
name: str
@@ -23,5 +30,10 @@ class DesignResponse(BaseModel):
icon: str | None = None
created_at: datetime
updated_at: datetime
# Populated by list_designs so the "copy from existing" picker can show what
# each canvas holds. None on create/update/copy responses (not computed there).
node_count: int | None = None
group_count: int | None = None
text_count: int | None = None
model_config = {"from_attributes": True}
+21 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
from pydantic import BaseModel, field_validator
from app.schemas.utils import normalize_animated
from app.schemas.utils import normalize_animated, normalize_marker
class EdgeBase(BaseModel):
@@ -14,7 +14,11 @@ class EdgeBase(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str = 'none'
marker_start: str = 'none'
marker_end: str = 'none'
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -24,6 +28,11 @@ class EdgeBase(BaseModel):
def validate_animated(cls, v: object) -> str:
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str:
return normalize_marker(v)
class EdgeCreate(EdgeBase):
design_id: str | None = None
@@ -36,7 +45,11 @@ class EdgeUpdate(BaseModel):
speed: str | None = None
custom_color: str | None = None
path_style: str | None = None
line_style: str | None = None
width_mult: float | None = None
animated: str | None = None
marker_start: str | None = None
marker_end: str | None = None
source_handle: str | None = None
target_handle: str | None = None
waypoints: list[dict[str, float]] | None = None
@@ -48,6 +61,13 @@ class EdgeUpdate(BaseModel):
return None
return normalize_animated(v)
@field_validator('marker_start', 'marker_end', mode='before')
@classmethod
def validate_marker(cls, v: object) -> str | None:
if v is None:
return None
return normalize_marker(v)
class EdgeResponse(EdgeBase):
id: str
+14
View File
@@ -32,10 +32,21 @@ class NodeBase(BaseModel):
width: float | None = None
height: float | None = None
bottom_handles: int = 1
top_handles: int = 1
left_handles: int = 0
right_handles: int = 0
class NodeCreate(NodeBase):
# Override pos_x/pos_y so callers can omit them; None signals "auto-place".
# The create_node route resolves None to a free grid slot before persisting.
pos_x: float | None = None # type: ignore[assignment]
pos_y: float | None = None # type: ignore[assignment]
design_id: str | None = None
# When a node with the same ip/mac already exists on the target design, the
# create/approve endpoints reject with 409 so the UI can ask the user. Set
# force=True to bypass that guard and create the duplicate deliberately.
force: bool = False
class NodeUpdate(BaseModel):
@@ -66,6 +77,9 @@ class NodeUpdate(BaseModel):
width: float | None = None
height: float | None = None
bottom_handles: int | None = None
top_handles: int | None = None
left_handles: int | None = None
right_handles: int | None = None
class NodeResponse(NodeBase):
+87
View File
@@ -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)
+13 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
from pydantic import BaseModel, field_validator
class PendingDeviceResponse(BaseModel):
@@ -14,12 +14,18 @@ class PendingDeviceResponse(BaseModel):
suggested_type: str | None
status: str
discovery_source: str | None
# All sources that have observed this device (e.g. ["arp", "proxmox"]). Drives
# the inventory source filter + badges; falls back to [discovery_source].
discovery_sources: list[str] = []
ieee_address: str | None = None
friendly_name: str | None = None
device_subtype: str | None = None
model: str | None = None
vendor: str | None = None
lqi: int | None = None
# Display properties carried from discovery (e.g. Proxmox specs). Merged into
# the node on approve; empty for scan/mesh sources that don't set them.
properties: list[Any] = []
discovered_at: datetime
# Number of distinct canvases (designs) this device already appears on,
# 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_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}
+18
View File
@@ -7,3 +7,21 @@ def normalize_animated(v: object) -> str:
if v in ('snake', 'flow', 'basic'):
return str(v)
return 'none'
MARKER_SHAPES = {'none', 'arrow', 'arrow-open', 'circle', 'diamond', 'square'}
def normalize_marker(v: object) -> str:
"""Normalize an edge endpoint marker to a shape string.
Legacy saves stored a boolean (True = filled arrow); coerce those and any
unknown value to a valid MarkerShape ('none' when off/unknown).
"""
if v is True or v == 1 or v == '1':
return 'arrow'
if v is False or v == 0 or v == '0' or v is None:
return 'none'
if isinstance(v, str) and v in MARKER_SHAPES:
return v
return 'none'
+26
View File
@@ -93,3 +93,29 @@ class ZigbeeImportPendingResponse(BaseModel):
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
class ZigbeeConfig(BaseModel):
"""Non-secret Zigbee connection + auto-sync config (GET response).
MQTT connection fields (host/port/base_topic/tls) are env-only and
read-only here — surfaced for display. ``host_configured`` reflects whether
a server-side MQTT host is set (required for auto-sync). MQTT credentials
(username/password) are never carried."""
mqtt_host: str = ""
mqtt_port: int = Field(1883, ge=1, le=65535)
base_topic: str = "zigbee2mqtt"
mqtt_tls: bool = False
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
host_configured: bool = False
class ZigbeeSyncConfig(BaseModel):
"""User-editable auto-sync config (POST body). The ONLY persisted Zigbee
settings. Connection fields (host/port/credentials/topic/tls) are env-only
and are deliberately not accepted here."""
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
+27
View File
@@ -83,3 +83,30 @@ class ZwaveImportPendingResponse(BaseModel):
coordinator_already_existed: bool = False
links_recorded: int
device_count: int
class ZwaveConfig(BaseModel):
"""Non-secret Z-Wave connection + auto-sync config (GET response).
MQTT connection fields (host/port/prefix/gateway_name/tls) are env-only and
read-only here — surfaced for display. ``host_configured`` reflects whether
a server-side MQTT host is set (required for auto-sync). MQTT credentials
(username/password) are never carried."""
mqtt_host: str = ""
mqtt_port: int = Field(1883, ge=1, le=65535)
prefix: str = "zwave"
gateway_name: str = "zwavejs2mqtt"
mqtt_tls: bool = False
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
host_configured: bool = False
class ZwaveSyncConfig(BaseModel):
"""User-editable auto-sync config (POST body). The ONLY persisted Z-Wave
settings. Connection fields (host/port/credentials/prefix/gateway/tls) are
env-only and are deliberately not accepted here."""
sync_enabled: bool = False
sync_interval: int = Field(3600, ge=300)
+19
View File
@@ -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
+18
View File
@@ -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
+241
View File
@@ -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
+424
View File
@@ -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)
+29 -13
View File
@@ -15,8 +15,10 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
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.http_probe import probe_open_ports
from app.services.mac_utils import normalize_mac
logger = logging.getLogger(__name__)
@@ -522,16 +524,21 @@ async def run_scan(
ip, open_ports, verify_tls=deep_scan.verify_tls
)
norm_mac = normalize_mac(host.get("mac"))
services = fingerprint_ports(open_ports)
suggested_type = suggest_node_type(open_ports, host.get("mac"))
suggested_type = suggest_node_type(open_ports, norm_mac)
# One inventory row per device (by IP). Match across pending AND
# approved so a re-scan of an already-approved device refreshes its
# row instead of spawning a fresh "pending" duplicate. Hidden rows
# are already skipped above.
# One inventory row per device. Match by IP OR MAC across pending AND
# approved so a re-scan refreshes the existing row instead of spawning
# a duplicate — and so a device previously imported from Proxmox (which
# may have no IP but a known NIC MAC) reconciles with this scan instead
# of doubling up. Hidden rows are already skipped above.
match_cond = [PendingDevice.ip == ip]
if norm_mac:
match_cond.append(PendingDevice.mac == norm_mac)
existing_rows = (await db.execute(
select(PendingDevice)
.where(PendingDevice.ip == ip, PendingDevice.status != "hidden")
.where(or_(*match_cond), PendingDevice.status != "hidden")
.order_by(PendingDevice.discovered_at)
)).scalars().all()
@@ -543,32 +550,41 @@ async def run_scan(
for dup in existing_rows:
if dup is not keep:
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.os = host.get("os") or keep.os
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.
else:
db.add(PendingDevice(
ip=ip,
mac=host.get("mac"),
mac=norm_mac,
hostname=host.get("hostname"),
os=host.get("os"),
services=services,
suggested_type=suggested_type,
status="pending",
discovery_source=discovery_source,
discovery_sources=[discovery_source],
))
devices_found += 1
# Stamp last_scan on any canvas node that matches this device by IP
# (or MAC, when known) so the inventory shows when the scanner last
# observed it. Matches across designs.
host_mac = host.get("mac")
# observed it. Match both the normalized and raw MAC so a legacy
# canvas node whose mac predates normalization still matches. Across
# designs.
node_match = [Node.ip == ip]
if host_mac:
node_match.append(Node.mac == host_mac)
for m in {norm_mac, host.get("mac")}:
if m:
node_match.append(Node.mac == m)
matching_nodes = (await db.execute(
select(Node).where(or_(*node_match))
)).scalars().all()
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+4 -7
View File
@@ -44,10 +44,7 @@ async def client(db_session: AsyncSession):
@pytest.fixture
def auth_headers(client):
"""Returns a coroutine that logs in and returns auth headers."""
async def _get():
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
return _get
async def headers(client: AsyncClient):
"""Authenticated Bearer headers for the default admin test user."""
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
View File
+84
View File
@@ -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
+52
View File
@@ -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
+723
View File
@@ -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"}
+336
View File
@@ -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)
+262
View File
@@ -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}
+431
View File
@@ -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)
+70 -7
View File
@@ -1,15 +1,8 @@
import uuid
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
@@ -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}
async def test_save_canvas_round_trips_marker_shapes(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start="diamond", marker_end="arrow")
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "diamond"
assert edge["marker_end"] == "arrow"
async def test_save_canvas_round_trips_line_style_and_width(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], type="wifi", line_style="dotted", width_mult=3)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["line_style"] == "dotted"
assert edge["width_mult"] == 3
async def test_save_canvas_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"], marker_start=True, marker_end=False)
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "arrow"
assert edge["marker_end"] == "none"
async def test_save_canvas_defaults_markers_none(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
n2 = node_payload(label="Switch", type="switch")
e1 = edge_payload(n1["id"], n2["id"])
await client.post("/api/v1/canvas/save", json={"nodes": [n1, n2], "edges": [e1], "viewport": {}}, headers=headers)
edge = (await client.get("/api/v1/canvas", headers=headers)).json()["edges"][0]
assert edge["marker_start"] == "none"
assert edge["marker_end"] == "none"
async def test_save_canvas_round_trips_per_side_handles(client: AsyncClient, headers: dict):
# Regression (#243): top/left/right_handles must persist across save+reload,
# not just bottom_handles.
n1 = node_payload(label="Cam", type="camera", top_handles=2, bottom_handles=3, left_handles=1, right_handles=4)
res = await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
assert res.status_code == 200
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
assert node["top_handles"] == 2
assert node["bottom_handles"] == 3
assert node["left_handles"] == 1
assert node["right_handles"] == 4
async def test_save_canvas_defaults_per_side_handles(client: AsyncClient, headers: dict):
# Nodes saved without the new fields fall back to top/bottom=1, left/right=0.
n1 = node_payload(label="Srv", type="server")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 1}}, headers=headers)
node = (await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0]
assert node["top_handles"] == 1
assert node["bottom_handles"] == 1
assert node["left_handles"] == 0
assert node["right_handles"] == 0
async def test_load_canvas_exposes_inventory_timestamps(client: AsyncClient, headers: dict):
n1 = node_payload(label="Router", type="router")
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
+112 -7
View File
@@ -1,15 +1,8 @@
import uuid
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
return {"Authorization": f"Bearer {res.json()['access_token']}"}
def node_payload(**kwargs):
return {"id": str(uuid.uuid4()), "type": "server", "label": "N", "status": "unknown", "pos_x": 0, "pos_y": 0, **kwargs}
@@ -100,6 +93,118 @@ async def test_list_returns_created_designs_ordered(client: AsyncClient, headers
assert ids == [a["id"], b["id"]]
# ── counts in list ──────────────────────────────────────────────────────────
async def test_list_includes_node_group_text_counts(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Counted")
server = node_payload(label="S", type="server")
group = node_payload(label="G", type="groupRect")
text = node_payload(label="T", type="text")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [server, group, text], "edges": [], "viewport": {}, "design_id": design["id"]},
headers=headers,
)
assert save.status_code == 200
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 1
assert d["group_count"] == 1
assert d["text_count"] == 1
async def test_list_counts_zero_for_empty_design(client: AsyncClient, headers: dict):
design = await _create(client, headers, name="Empty")
listed = (await client.get("/api/v1/designs", headers=headers)).json()
d = next(x for x in listed if x["id"] == design["id"])
assert d["node_count"] == 0
assert d["group_count"] == 0
assert d["text_count"] == 0
# ── copy ──────────────────────────────────────────────────────────────────────
async def test_copy_requires_auth(client: AsyncClient):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"})
assert res.status_code == 401
async def test_copy_missing_source_returns_404(client: AsyncClient, headers: dict):
res = await client.post(f"/api/v1/designs/{uuid.uuid4()}/copy", json={"name": "X"}, headers=headers)
assert res.status_code == 404
async def test_copy_duplicates_nodes_edges_and_remaps_ids(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source", icon="server")
n1 = node_payload(label="A")
n2 = node_payload(label="B")
e1 = edge_payload(n1["id"], n2["id"], label="link")
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1, n2], "edges": [e1], "viewport": {"x": 5, "y": 6, "zoom": 2}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(
f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy", "icon": "network"}, headers=headers,
)
assert res.status_code == 201, res.text
copy = res.json()
assert copy["name"] == "Copy"
assert copy["icon"] == "network"
assert copy["design_type"] == "network"
assert copy["id"] != source["id"]
# Copied canvas has the same shape but fresh node ids, and edge re-pointed.
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
assert {n["label"] for n in canvas["nodes"]} == {"A", "B"}
copied_ids = {n["id"] for n in canvas["nodes"]}
assert copied_ids.isdisjoint({n1["id"], n2["id"]})
assert len(canvas["edges"]) == 1
edge = canvas["edges"][0]
assert edge["source"] in copied_ids
assert edge["target"] in copied_ids
assert edge["label"] == "link"
assert canvas["viewport"] == {"x": 5, "y": 6, "zoom": 2}
async def test_copy_remaps_parent_child_relationship(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Nested")
parent = node_payload(label="P", type="proxmox", container_mode=True)
child = node_payload(label="C", type="vm", parent_id=parent["id"])
save = await client.post(
"/api/v1/canvas/save",
json={"nodes": [parent, child], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
assert save.status_code == 200
res = await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
assert res.status_code == 201
copy = res.json()
canvas = (await client.get("/api/v1/canvas", params={"design_id": copy["id"]}, headers=headers)).json()
by_label = {n["label"]: n for n in canvas["nodes"]}
# Child's parent_id points at the COPIED parent, not the original.
assert by_label["C"]["parent_id"] == by_label["P"]["id"]
assert by_label["C"]["parent_id"] != parent["id"]
async def test_copy_leaves_source_untouched(client: AsyncClient, headers: dict):
source = await _create(client, headers, name="Source")
n1 = node_payload(label="A")
await client.post(
"/api/v1/canvas/save",
json={"nodes": [n1], "edges": [], "viewport": {}, "design_id": source["id"]},
headers=headers,
)
await client.post(f"/api/v1/designs/{source['id']}/copy", json={"name": "Copy"}, headers=headers)
src_canvas = (await client.get("/api/v1/canvas", params={"design_id": source["id"]}, headers=headers)).json()
assert len(src_canvas["nodes"]) == 1
assert src_canvas["nodes"][0]["id"] == n1["id"]
# ── update ────────────────────────────────────────────────────────────────────
async def test_update_design_renames(client: AsyncClient, headers: dict):
+174 -7
View File
@@ -2,13 +2,6 @@ import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
async def two_nodes(client: AsyncClient, headers: dict):
n1 = (await client.post("/api/v1/nodes", json={"type": "router", "label": "R1", "status": "online"}, headers=headers)).json()
@@ -91,6 +84,70 @@ async def test_update_edge_custom_color_and_path_style(client: AsyncClient, head
assert res.json()["path_style"] == "smooth"
async def test_create_edge_with_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "wifi", "line_style": "dotted", "width_mult": 3}, headers=headers)
assert res.status_code == 201
assert res.json()["line_style"] == "dotted"
assert res.json()["width_mult"] == 3
async def test_update_edge_line_style_and_width(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"line_style": "dashed", "width_mult": 2}, headers=headers)
assert res.status_code == 200
assert res.json()["line_style"] == "dashed"
assert res.json()["width_mult"] == 2
async def test_create_edge_defaults_line_style_none(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
assert res.status_code == 201
assert res.json()["line_style"] is None
assert res.json()["width_mult"] is None
async def test_create_edge_with_marker_shapes(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_start": "diamond", "marker_end": "arrow"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] == "diamond"
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_defaults_markers_none(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_start"] == "none"
assert res.json()["marker_end"] == "none"
async def test_create_edge_coerces_legacy_boolean_marker(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": True}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "arrow"
async def test_create_edge_rejects_unknown_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet", "marker_end": "bogus"}, headers=headers)
assert res.status_code == 201
assert res.json()["marker_end"] == "none"
async def test_update_edge_marker_shape(client: AsyncClient, headers: dict, two_nodes):
src, tgt = two_nodes
edge_id = (await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers)).json()["id"]
res = await client.patch(f"/api/v1/edges/{edge_id}", json={"marker_end": "circle"}, headers=headers)
assert res.status_code == 200
assert res.json()["marker_end"] == "circle"
assert res.json()["marker_start"] == "none"
async def test_create_edge_requires_auth(client: AsyncClient, two_nodes):
src, tgt = two_nodes
res = await client.post("/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"})
@@ -163,3 +220,113 @@ async def test_source_and_target_handle_persist_through_update(client: AsyncClie
assert data["source_handle"] == "cluster-right"
assert data["target_handle"] == "cluster-left"
assert data["label"] == "corosync"
# ---------------------------------------------------------------------------
# Auto edge handles (issue #265): omitting source_handle/target_handle lets
# the backend infer up/downstream sides from the nodes' absolute canvas Y.
# ---------------------------------------------------------------------------
@pytest.fixture
async def stacked_nodes(client: AsyncClient, headers: dict):
"""Two root nodes at known, distinct Y positions (top at y=0, bottom at y=300)."""
top = (
await client.post(
"/api/v1/nodes",
json={"type": "router", "label": "Top", "status": "online", "pos_x": 0, "pos_y": 0},
headers=headers,
)
).json()
bottom = (
await client.post(
"/api/v1/nodes",
json={"type": "switch", "label": "Bottom", "status": "online", "pos_x": 0, "pos_y": 300},
headers=headers,
)
).json()
return top["id"], bottom["id"]
async def test_create_edge_auto_handles_source_above_target(client: AsyncClient, headers: dict, stacked_nodes):
"""Source above target -> downstream flow: exit bottom, enter top-t."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges", json={"source": top, "target": bottom, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
async def test_create_edge_auto_handles_source_below_target(client: AsyncClient, headers: dict, stacked_nodes):
"""Source below target -> upstream flow: exit top, enter bottom-t."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges", json={"source": bottom, "target": top, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "top"
assert data["target_handle"] == "bottom-t"
async def test_create_edge_auto_handles_equal_y_defaults_downstream(client: AsyncClient, headers: dict, two_nodes):
"""Same Y (auto-placed side by side) falls back to the default bottom/top-t."""
src, tgt = two_nodes
res = await client.post(
"/api/v1/edges", json={"source": src, "target": tgt, "type": "ethernet"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
async def test_create_edge_partial_handle_auto_fills_the_other(client: AsyncClient, headers: dict, stacked_nodes):
"""An explicit source_handle is kept; the omitted target_handle is inferred."""
top, bottom = stacked_nodes
res = await client.post(
"/api/v1/edges",
json={"source": top, "target": bottom, "type": "ethernet", "source_handle": "cluster-right"},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "cluster-right"
assert data["target_handle"] == "top-t"
async def test_create_edge_child_node_abs_y_resolved_through_parent(client: AsyncClient, headers: dict):
"""A child's absolute Y = parent Y + its own pos_y, so a VM low inside a high
container still resolves as downstream of a node above the container."""
parent = (
await client.post(
"/api/v1/nodes",
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True, "pos_x": 0, "pos_y": 0},
headers=headers,
)
).json()
child = (
await client.post(
"/api/v1/nodes",
json={"type": "vm", "label": "VM", "status": "online", "parent_id": parent["id"], "pos_x": 0, "pos_y": 50},
headers=headers,
)
).json()
below = (
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "Below", "status": "online", "pos_x": 0, "pos_y": 500},
headers=headers,
)
).json()
# child abs Y = 0 + 50 = 50, below at 500 -> child is upstream.
res = await client.post(
"/api/v1/edges", json={"source": child["id"], "target": below["id"], "type": "virtual"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert data["source_handle"] == "bottom"
assert data["target_handle"] == "top-t"
+6 -12
View File
@@ -82,10 +82,9 @@ async def test_liveview_does_not_require_jwt(client: AsyncClient):
@pytest.mark.asyncio
async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
async def test_liveview_returns_saved_canvas(client: AsyncClient, headers):
"""Canvas saved via POST /canvas/save appears in liveview response."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Save a canvas with one node
payload = {
@@ -115,10 +114,9 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
# ── custom_style + theme propagation ─────────────────────────────────────────
@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."""
settings.liveview_key = "test-key"
headers = await auth_headers()
payload = {
"nodes": [],
"edges": [],
@@ -159,9 +157,8 @@ async def test_liveview_config_requires_auth(client: AsyncClient):
@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"
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
body = res.json()
@@ -169,18 +166,16 @@ async def test_liveview_config_returns_key_when_enabled(client: AsyncClient, aut
@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
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
assert res.json() == {"enabled": False, "key": None}
@pytest.mark.asyncio
async def test_liveview_config_empty_key_disabled(client: AsyncClient, auth_headers):
async def test_liveview_config_empty_key_disabled(client: AsyncClient, headers):
settings.liveview_key = ""
headers = await auth_headers()
res = await client.get("/api/v1/liveview/config", headers=headers)
assert res.status_code == 200
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 ───────────────────────────────
@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."""
settings.liveview_key = "test-key"
headers = await auth_headers()
# Create two designs
d1 = (await client.post("/api/v1/designs", json={"name": "Network"}, headers=headers)).json()
+27
View File
@@ -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"]
+94
View File
@@ -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
+117
View File
@@ -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
View File
@@ -1,14 +1,6 @@
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
async def test_list_nodes_empty(client: AsyncClient, headers: dict):
res = await client.get("/api/v1/nodes", headers=headers)
assert res.status_code == 200
@@ -99,6 +91,47 @@ async def test_create_node_respects_explicit_design_id(client: AsyncClient, head
assert res.json()["design_id"] == second_id
async def test_create_node_rejects_duplicate_ip_on_same_design(client: AsyncClient, headers: dict):
# A second node with the same ip on the same design is a silent duplicate —
# scripts/MCP clients get 409 with the existing node id instead. (#260)
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
design_id = design.json()["id"]
first = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
assert first.status_code == 201
existing_id = first.json()["id"]
dup = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv-again", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
assert dup.status_code == 409
detail = dup.json()["detail"]
assert detail["duplicate"] is True
assert detail["existing_node_id"] == existing_id
assert detail["match"] == "ip"
async def test_create_node_force_bypasses_duplicate_guard(client: AsyncClient, headers: dict):
design = await client.post("/api/v1/designs", json={"name": "D"}, headers=headers)
design_id = design.json()["id"]
await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id},
headers=headers,
)
forced = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "srv", "ip": "192.168.1.5", "design_id": design_id, "force": True},
headers=headers,
)
assert forced.status_code == 201
async def test_create_node_without_any_design_stays_null(client: AsyncClient, headers: dict):
# No designs exist yet: fallback can't invent one, so design_id stays null
# rather than erroring.
@@ -253,3 +286,80 @@ async def test_properties_icon_can_be_null(client: AsyncClient, headers: dict):
)
assert create.status_code == 201
assert create.json()["properties"] == props
# ---------------------------------------------------------------------------
# Auto-positioning (issue #265): omitting pos_x/pos_y snaps the node to the
# first free 200x100 grid slot instead of stacking everything at (0, 0).
# ---------------------------------------------------------------------------
async def test_create_node_auto_positions_first_at_origin(client: AsyncClient, headers: dict):
"""First root node with no coordinates lands at (0, 0)."""
res = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
async def test_create_node_auto_position_avoids_collision(client: AsyncClient, headers: dict):
"""A second auto-placed root node takes the next free grid cell, not (0, 0)."""
first = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
assert (first.json()["pos_x"], first.json()["pos_y"]) == (0.0, 0.0)
second = await client.post(
"/api/v1/nodes", json={"type": "server", "label": "B", "status": "unknown"}, headers=headers
)
assert second.status_code == 201
# Next free cell in row 0 is column 1 -> x = 1 * 200.
assert (second.json()["pos_x"], second.json()["pos_y"]) == (200.0, 0.0)
async def test_create_node_child_defaults_to_parent_origin(client: AsyncClient, headers: dict):
"""A child node (parent_id set) with no coordinates defaults to (0, 0) relative to its parent."""
parent = (
await client.post(
"/api/v1/nodes",
json={"type": "proxmox", "label": "PVE", "status": "online", "container_mode": True},
headers=headers,
)
).json()
res = await client.post(
"/api/v1/nodes",
json={"type": "vm", "label": "VM1", "status": "online", "parent_id": parent["id"]},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (0.0, 0.0)
async def test_create_node_explicit_position_preserved(client: AsyncClient, headers: dict):
"""Explicit coordinates are honored, never auto-placed."""
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "A", "status": "unknown", "pos_x": 512.0, "pos_y": 384.0},
headers=headers,
)
assert res.status_code == 201
data = res.json()
assert (data["pos_x"], data["pos_y"]) == (512.0, 384.0)
async def test_create_node_explicit_zero_position_preserved(client: AsyncClient, headers: dict):
"""pos=0 is an explicit value, not 'omitted' — auto-position must not treat 0 as None."""
await client.post(
"/api/v1/nodes", json={"type": "server", "label": "A", "status": "unknown"}, headers=headers
)
# Second node explicitly pinned to (0, 0) even though the cell is taken.
res = await client.post(
"/api/v1/nodes",
json={"type": "server", "label": "B", "status": "unknown", "pos_x": 0, "pos_y": 0},
headers=headers,
)
assert res.status_code == 201
assert (res.json()["pos_x"], res.json()["pos_y"]) == (0.0, 0.0)
+489
View File
@@ -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
+220
View File
@@ -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
+38
View File
@@ -577,6 +577,44 @@ async def test_run_scan_mdns_only_device_added(mem_db):
assert device.discovery_source == "mdns"
@pytest.mark.asyncio
async def test_run_scan_merges_proxmox_row_by_mac(mem_db):
"""A scan reconciles a prior Proxmox-imported row by MAC: fills the IP,
unions the source, keeps the vm type, and does not duplicate."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
# Previously imported from Proxmox: no IP, known NIC MAC, vm type.
session.add(PendingDevice(
id="pve-row", ieee_address="pve-pve1-101", ip=None,
mac="bc:24:11:aa:bb:cc", suggested_type="vm", status="pending",
discovery_source="proxmox", discovery_sources=["proxmox"],
))
await session.commit()
# Scan sees the same box (same MAC, different casing) with a live IP.
nmap_hosts = [{"ip": "192.168.1.50", "hostname": "web.lan",
"mac": "BC:24:11:AA:BB:CC", "os": None, "open_ports": []}]
async with mem_db() as session:
with patch("app.services.scanner._nmap_scan", return_value=nmap_hosts), \
patch("app.services.scanner._mdns_discover", new_callable=AsyncMock, return_value=[]), \
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
rows = (await session.execute(sa_select(PendingDevice))).scalars().all()
assert len(rows) == 1 # merged, not duplicated
row = rows[0]
assert row.ip == "192.168.1.50" # scan filled the IP
assert row.mac == "bc:24:11:aa:bb:cc" # normalized
assert row.suggested_type == "vm" # kept proxmox type
assert set(row.discovery_sources) == {"proxmox", "arp"} # both filters
@pytest.mark.asyncio
async def test_run_scan_mdns_skipped_if_already_in_nmap(mem_db):
"""If nmap and mDNS both find the same IP, it should not be double-counted."""
+300 -1
View File
@@ -6,9 +6,20 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.scheduler import (
_run_proxmox_sync,
_run_service_checks,
_run_status_checks,
_run_zigbee_sync,
_run_zwave_sync,
reschedule_proxmox_sync,
reschedule_service_checks,
reschedule_status_checks,
reschedule_zigbee_sync,
reschedule_zwave_sync,
set_proxmox_sync_enabled,
set_service_checks_enabled,
set_zigbee_sync_enabled,
set_zwave_sync_enabled,
start_scheduler,
stop_scheduler,
)
@@ -148,6 +159,9 @@ def test_scheduler_uses_settings_interval():
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 45
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
_, kwargs = mock_sched.add_job.call_args
assert kwargs["seconds"] == 45
@@ -156,7 +170,13 @@ def test_scheduler_uses_settings_interval():
def test_start_and_stop_scheduler():
"""Scheduler can be started and stopped without errors."""
mock_sched = MagicMock()
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
with patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
stop_scheduler()
mock_sched.add_job.assert_called_once()
@@ -245,7 +265,286 @@ def test_start_scheduler_adds_service_job_when_enabled():
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = True
mock_settings.service_check_interval = 300
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = False
mock_settings.zwave_sync_enabled = False
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "status_checks" in job_ids
assert "service_checks" in job_ids
# ---------------------------------------------------------------------------
# Proxmox auto-sync job
# ---------------------------------------------------------------------------
def test_set_proxmox_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_interval = 3600
mock_sched.get_job.return_value = None
set_proxmox_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_proxmox_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("proxmox_sync")
def test_reschedule_proxmox_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_proxmox_sync(60)
@pytest.mark.asyncio
async def test_run_proxmox_sync_skips_when_disabled():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_enabled = False
# Must return before importing/fetching anything.
await _run_proxmox_sync()
@pytest.mark.asyncio
async def test_run_proxmox_sync_skips_when_no_token():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.proxmox_sync_enabled = True
mock_settings.proxmox_host = "pve"
mock_settings.proxmox_token_id = ""
mock_settings.proxmox_token_secret = ""
await _run_proxmox_sync() # no exception, no fetch
def _proxmox_settings(mock_settings):
mock_settings.proxmox_sync_enabled = True
mock_settings.proxmox_host = "pve"
mock_settings.proxmox_port = 8006
mock_settings.proxmox_token_id = "root@pam!tok"
mock_settings.proxmox_token_secret = "secret"
mock_settings.proxmox_verify_tls = False
@pytest.mark.asyncio
async def test_run_proxmox_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=proxmox) so it shows in Scan
history, then delegate to the shared background import with the run id."""
from app.db.models import ScanRun
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch(
"app.api.routes.proxmox._background_proxmox_import",
new_callable=AsyncMock,
) as mock_bg,
):
_proxmox_settings(mock_settings)
await _run_proxmox_sync()
# A ScanRun row exists — the missing scan-history trace.
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "proxmox"
assert run.ranges == ["pve:8006"]
# Delegated to the same background flow the manual /sync-now uses, passing
# the run id + env connection settings.
mock_bg.assert_awaited_once_with(
run.id, "pve", 8006, "root@pam!tok", "secret", False,
)
# ---------------------------------------------------------------------------
# Zigbee / Z-Wave auto-sync jobs (MQTT mesh imports)
# ---------------------------------------------------------------------------
def test_set_zigbee_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zigbee_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zigbee_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zigbee_sync")
def test_set_zwave_sync_enabled_adds_and_removes_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched), \
patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_interval = 3600
mock_sched.get_job.return_value = None
set_zwave_sync_enabled(True)
mock_sched.add_job.assert_called_once()
mock_sched.get_job.return_value = MagicMock()
set_zwave_sync_enabled(False)
mock_sched.remove_job.assert_called_once_with("zwave_sync")
def test_reschedule_zigbee_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zigbee_sync(60)
def test_reschedule_zwave_sync_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_zwave_sync(60)
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_disabled():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = False
await _run_zigbee_sync() # returns before importing/fetching anything
@pytest.mark.asyncio
async def test_run_zigbee_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = ""
await _run_zigbee_sync() # no exception, no fetch
@pytest.mark.asyncio
async def test_run_zwave_sync_skips_when_no_host():
with patch("app.core.scheduler.settings") as mock_settings:
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = ""
await _run_zwave_sync()
@pytest.mark.asyncio
async def test_run_zigbee_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zigbee) so it shows in Scan history,
then delegate to the shared background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zigbee import ZigbeeImportRequest
fake_payload = ZigbeeImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zigbee.env_import_request", return_value=fake_payload),
):
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_mqtt_host = "broker"
mock_settings.zigbee_mqtt_port = 1883
await _run_zigbee_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zigbee"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
@pytest.mark.asyncio
async def test_run_zwave_sync_records_scan_run(mem_db):
"""Auto-sync must create a ScanRun (kind=zwave) then delegate to the shared
background import with the run id + env payload."""
from app.db.models import ScanRun
from app.schemas.zwave import ZwaveImportRequest
fake_payload = ZwaveImportRequest(mqtt_host="broker", mqtt_port=1883)
with (
patch("app.core.scheduler.settings") as mock_settings,
patch("app.core.scheduler.AsyncSessionLocal", mem_db),
patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock) as mock_bg,
patch("app.api.routes.zwave.env_import_request", return_value=fake_payload),
):
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_mqtt_host = "broker"
mock_settings.zwave_mqtt_port = 1883
await _run_zwave_sync()
async with mem_db() as db:
from sqlalchemy import select
run = (await db.execute(select(ScanRun))).scalars().one()
assert run.kind == "zwave"
assert run.ranges == ["broker:1883"]
mock_bg.assert_awaited_once_with(run.id, fake_payload)
def test_reschedule_zigbee_sync_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_zigbee_sync(600)
mock_sched.reschedule_job.assert_not_called()
def test_start_scheduler_adds_mesh_jobs_when_enabled():
mock_sched = MagicMock()
with patch("app.core.scheduler.settings") as mock_settings, \
patch("app.core.scheduler.AsyncIOScheduler", return_value=mock_sched):
mock_settings.status_checker_interval = 60
mock_settings.service_check_enabled = False
mock_settings.proxmox_sync_enabled = False
mock_settings.zigbee_sync_enabled = True
mock_settings.zigbee_sync_interval = 3600
mock_settings.zwave_sync_enabled = True
mock_settings.zwave_sync_interval = 3600
start_scheduler()
job_ids = [kw.get("id") for _, kw in mock_sched.add_job.call_args_list]
assert "zigbee_sync" in job_ids
assert "zwave_sync" in job_ids
# --- reschedule_* validation and not-running guards ---
def test_reschedule_status_checks_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_status_checks(5)
def test_reschedule_status_checks_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_status_checks(60)
mock_sched.reschedule_job.assert_not_called()
def test_reschedule_status_checks_updates_running_job():
mock_sched = MagicMock()
mock_sched.running = True
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_status_checks(60)
mock_sched.reschedule_job.assert_called_once_with(
"status_checks", trigger="interval", seconds=60,
)
def test_reschedule_service_checks_rejects_short_interval():
with pytest.raises(ValueError):
reschedule_service_checks(10)
def test_reschedule_service_checks_updates_when_job_exists():
mock_sched = MagicMock()
mock_sched.running = True
mock_sched.get_job.return_value = MagicMock()
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_service_checks(60)
mock_sched.reschedule_job.assert_called_once_with(
"service_checks", trigger="interval", seconds=60,
)
def test_reschedule_proxmox_sync_noop_when_not_running():
mock_sched = MagicMock()
mock_sched.running = False
with patch("app.core.scheduler.scheduler", mock_sched):
reschedule_proxmox_sync(600)
mock_sched.reschedule_job.assert_not_called()
+89 -6
View File
@@ -1,15 +1,11 @@
"""Tests for GET/POST /api/v1/settings."""
import json
from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
from app.core.config import Settings
@pytest.mark.asyncio
@@ -84,3 +80,90 @@ async def test_update_settings_rejects_too_short_service_interval(client: AsyncC
headers=headers,
)
assert res.status_code == 422
def test_proxmox_connection_config_is_env_only_never_from_overrides(tmp_path):
"""Connection config (host/port/verify_tls) is env-only: a stale value in
scan_config.json must be ignored so it can never clobber the env. Only the
auto-sync activation is read back."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.proxmox_host = "pve.local" # as if set from env
s.proxmox_port = 8006
s.proxmox_verify_tls = True
(tmp_path / "scan_config.json").write_text(json.dumps({
"proxmox_host": "stale-host",
"proxmox_port": 9999,
"proxmox_verify_tls": False,
"proxmox_sync_enabled": True,
"proxmox_sync_interval": 600,
}))
s.load_overrides()
# Connection config untouched by the file (env values survive).
assert s.proxmox_host == "pve.local"
assert s.proxmox_port == 8006
assert s.proxmox_verify_tls is True
# Auto-sync activation is the only thing loaded.
assert s.proxmox_sync_enabled is True
assert s.proxmox_sync_interval == 600
def test_save_overrides_omits_proxmox_connection_config(tmp_path):
"""save_overrides must never write host/port/verify_tls (nor the token) —
only the sync activation. This is what prevents the dual source of truth."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.proxmox_host = "pve.local"
s.proxmox_sync_enabled = True
s.proxmox_sync_interval = 900
s.save_overrides()
written = json.loads((tmp_path / "scan_config.json").read_text())
assert "proxmox_host" not in written
assert "proxmox_port" not in written
assert "proxmox_verify_tls" not in written
assert "proxmox_token_id" not in written
assert "proxmox_token_secret" not in written
assert written["proxmox_sync_enabled"] is True
assert written["proxmox_sync_interval"] == 900
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
env-only: stale values in scan_config.json must be ignored. Only the
auto-sync activation is read back."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zwave_mqtt_host = "broker.local"
(tmp_path / "scan_config.json").write_text(json.dumps({
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
}))
s.load_overrides()
assert s.zigbee_mqtt_host == "broker.local"
assert s.zwave_mqtt_host == "broker.local"
assert s.zigbee_sync_enabled is True
assert s.zigbee_sync_interval == 600
assert s.zwave_sync_enabled is True
assert s.zwave_sync_interval == 700
def test_save_overrides_omits_mesh_credentials(tmp_path):
"""save_overrides must never write MQTT host/credentials — only the sync
activation. Prevents the dual source of truth and leaking secrets to disk."""
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
s.zigbee_mqtt_host = "broker.local"
s.zigbee_mqtt_password = "secret"
s.zigbee_sync_enabled = True
s.zigbee_sync_interval = 900
s.zwave_mqtt_password = "secret"
s.zwave_sync_interval = 1200
s.save_overrides()
raw = (tmp_path / "scan_config.json").read_text()
assert "secret" not in raw
written = json.loads(raw)
assert "zigbee_mqtt_host" not in written
assert "zigbee_mqtt_password" not in written
assert "zwave_mqtt_password" not in written
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
+45
View File
@@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from app.services.status_checker import (
_http_get,
_ping,
_tcp_connect,
check_node,
@@ -11,6 +12,18 @@ from app.services.status_checker import (
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 ---
@pytest.mark.asyncio
@@ -462,3 +475,35 @@ async def test_check_services_returns_status_per_service():
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
# --- _http_get status-code interpretation (real primitive, mocked transport) ---
@pytest.mark.asyncio
async def test_http_get_true_on_2xx():
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(200)):
assert await _http_get("http://host") is True
@pytest.mark.asyncio
async def test_http_get_true_on_4xx():
# A 4xx means the server is up and answering — still "online".
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(404)):
assert await _http_get("http://host") is True
@pytest.mark.asyncio
async def test_http_get_false_on_5xx():
with patch("app.services.status_checker.httpx.AsyncClient", _mock_httpx_client(503)):
assert await _http_get("http://host") is False
@pytest.mark.asyncio
async def test_check_service_http_exception_returns_offline():
svc = {"port": 80, "protocol": "tcp", "service_name": "http"}
with patch(
"app.services.status_checker._http_get",
new_callable=AsyncMock,
side_effect=RuntimeError("connection refused"),
):
assert await check_service(svc, "10.0.0.1") == "offline"
+237 -39
View File
@@ -2,7 +2,7 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
@@ -11,13 +11,6 @@ from httpx import AsyncClient
# 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
# ---------------------------------------------------------------------------
@@ -338,20 +331,36 @@ async def test_import_pending_endpoint_creates_zigbee_scan_run(
@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,
) -> None:
"""Coordinator is no longer auto-placed — it lands in pending like the rest."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 2
assert result.pending_created == 3 # coordinator included now
assert result.pending_updated == 0
assert result.coordinator is not None
assert result.coordinator.ieee_address == "0xCOORD"
assert result.coordinator is None # not auto-placed
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
# No canvas Node auto-created for the coordinator.
nodes = (await db_session.execute(select(Node))).scalars().all()
assert nodes == []
# Coordinator sits in the pending inventory.
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xCOORD")
)
).scalar_one()
assert coord.status == "pending"
assert coord.suggested_type == "zigbee_coordinator"
assert coord.device_subtype == "Coordinator"
@pytest.mark.asyncio
async def test_persist_pending_import_idempotent_updates_existing(
@@ -366,8 +375,8 @@ async def test_persist_pending_import_idempotent_updates_existing(
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 2
assert result.coordinator_already_existed is True
assert result.pending_updated == 3 # coordinator upserts too
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
@@ -389,12 +398,12 @@ async def test_persist_pending_import_replaces_links(db_session) -> None:
@pytest.mark.asyncio
async def test_persist_pending_import_sets_coordinator_properties(db_session) -> None:
"""Coordinator Node is created with IEEE/Vendor/Model/LQI in properties."""
async def test_persist_pending_import_sets_coordinator_pending_fields(db_session) -> None:
"""Coordinator lands in pending carrying its vendor/model metadata."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
from app.db.models import PendingDevice
nodes_with_meta = [dict(n) for n in _PENDING_NODES]
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)
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()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys == {"IEEE": "0xCOORD", "Vendor": "TI", "Model": "CC2652"}
# New zigbee props default to hidden — user opts in from the right panel.
assert all(p["visible"] is False for p in coord.properties)
assert coord.vendor == "TI"
assert coord.model == "CC2652"
assert coord.suggested_type == "zigbee_coordinator"
@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,
) -> None:
"""A device already approved as a canvas Node must not reappear in pending.
Its properties must still be refreshed with the latest Vendor/Model/LQI.
"""A device on canvas but missing its inventory row gets one backfilled
(status="approved"), so it shows in the inventory list. Node props still
refresh with the latest Vendor/Model/LQI.
"""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
# Simulate: router was approved earlier → exists as a canvas Node.
# Simulate: router was approved earlier → exists as a canvas Node, but with
# no matching pending_devices row (e.g. a legacy auto-placed device).
approved = Node(
label="router_1",
type="zigbee_router",
@@ -441,13 +452,15 @@ async def test_persist_pending_import_skips_pending_for_approved_node(
bumped[1]["lqi"] = 250 # new LQI from re-import
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
# No PendingDevice row was created for the approved router.
pendings = (
# An inventory row is backfilled as "approved" (it is on a canvas).
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalars().all()
assert pendings == []
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zigbee_router"
assert inv.device_subtype == "Router"
# Node properties got 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)
@pytest.mark.asyncio
async def test_persist_pending_import_preserves_hidden_inventory_for_approved_node(
db_session,
) -> None:
"""If the on-canvas device already has a hidden inventory row, re-import
refreshes its metadata but must NOT flip it back to approved/visible."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node, PendingDevice
db_session.add(Node(
label="router_1", type="zigbee_router", status="online",
check_method="none", ieee_address="0xR1", services=[], properties=[],
))
db_session.add(PendingDevice(
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
device_subtype="Router", status="hidden", discovery_source="zigbee",
))
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "0xR1")
)
).scalar_one()
assert inv.status == "hidden" # stays hidden
@pytest.mark.asyncio
async def test_persist_pending_import_revives_orphaned_approved_device(
db_session,
@@ -501,8 +545,8 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
)
).scalar_one()
assert revived.status == "pending"
# End device 0xE1 is brand new → created as pending; router was updated.
assert result.pending_created == 1
# Coordinator + end device 0xE1 are brand new → created; router was revived.
assert result.pending_created == 2
assert result.pending_updated == 1
# It is now visible to the Pending list (status filter == "pending").
@@ -511,7 +555,7 @@ async def test_persist_pending_import_revives_orphaned_approved_device(
select(PendingDevice).where(PendingDevice.status == "pending")
)
).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
@@ -591,15 +635,23 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
@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,
) -> None:
"""An already-approved coordinator Node gets its props refreshed on
re-import, and a backfilled inventory row (approved) so it shows in the
inventory list this is the legacy auto-placed coordinator scenario."""
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
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[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}
assert keys["Vendor"] == "TI"
assert keys["Model"] == "CC2652"
# Newly added keys on re-import default to hidden.
by_key = {p["key"]: p for p in coord.properties}
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
@@ -645,3 +740,106 @@ async def test_test_connection_with_tls(client: AsyncClient, headers: dict) -> N
kwargs = mock_conn.call_args.kwargs
assert kwargs["tls"] is True
assert kwargs["tls_insecure"] is True
# ---------------------------------------------------------------------------
# /api/v1/zigbee/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zigbee_env():
"""Snapshot + restore the env-only Zigbee connection/activation settings."""
keys = (
"zigbee_mqtt_host", "zigbee_mqtt_port", "zigbee_mqtt_username",
"zigbee_mqtt_password", "zigbee_base_topic", "zigbee_sync_enabled",
"zigbee_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
settings.zigbee_mqtt_username = "user"
settings.zigbee_mqtt_password = "supersecret"
res = await client.get("/api/v1/zigbee/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zigbee/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post(
"/api/v1/zigbee/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zigbee_mqtt_host, enabled=self.zigbee_sync_enabled,
interval=self.zigbee_sync_interval,
)), patch("app.api.routes.zigbee.set_zigbee_sync_enabled"), \
patch("app.api.routes.zigbee.reschedule_zigbee_sync"):
res = await client.post(
"/api/v1/zigbee/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zigbee_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = "broker"
with patch("app.api.routes.zigbee._background_zigbee_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zigbee"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zigbee_env
) -> None:
settings.zigbee_mqtt_host = ""
res = await client.post("/api/v1/zigbee/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zigbee/sync-now")
assert res.status_code == 401
+177 -25
View File
@@ -7,14 +7,6 @@ from unittest.mock import patch
import pytest
from httpx import AsyncClient
@pytest.fixture
async def headers(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
token = res.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
# ---------------------------------------------------------------------------
# /api/v1/zwave/test-connection
# ---------------------------------------------------------------------------
@@ -297,18 +289,31 @@ async def test_import_pending_requires_auth(client: AsyncClient) -> None:
@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.db.models import Node, PendingDevice
result = await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
assert result.device_count == 3
assert result.pending_created == 2
assert result.pending_created == 3 # coordinator included now
assert result.pending_updated == 0
assert result.coordinator is not None
assert result.coordinator.ieee_address == "zwave-0xh-1"
assert result.coordinator is None # not auto-placed
assert result.coordinator_already_existed is False
assert result.links_recorded == 2
nodes = (await db_session.execute(select(Node))).scalars().all()
assert nodes == []
coord = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-1")
)
).scalar_one()
assert coord.status == "pending"
assert coord.suggested_type == "zwave_coordinator"
@pytest.mark.asyncio
async def test_persist_idempotent_updates_existing(db_session) -> None:
@@ -319,8 +324,8 @@ async def test_persist_idempotent_updates_existing(db_session) -> None:
bumped[1]["model"] = "ZW111"
result = await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
assert result.pending_created == 0
assert result.pending_updated == 2
assert result.coordinator_already_existed is True
assert result.pending_updated == 3 # coordinator upserts too
assert result.coordinator_already_existed is False
@pytest.mark.asyncio
@@ -339,26 +344,31 @@ async def test_persist_replaces_links(db_session) -> None:
@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 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[0]["vendor"] = "Aeotec"
nodes[0]["model"] = "ZW090"
await _persist_pending_import(db_session, nodes, _PENDING_EDGES)
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()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys == {"Z-Wave ID": "zwave-0xh-1", "Vendor": "Aeotec", "Model": "ZW090"}
assert all(p["visible"] is False for p in coord.properties)
assert coord.vendor == "Aeotec"
assert coord.model == "ZW090"
assert coord.suggested_type == "zwave_coordinator"
@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 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)
pendings = (
inv = (
await db_session.execute(
select(PendingDevice).where(PendingDevice.ieee_address == "zwave-0xh-2")
)
).scalars().all()
assert pendings == []
).scalar_one()
assert inv.status == "approved"
assert inv.suggested_type == "zwave_router"
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
).scalar_one()
@@ -416,7 +427,8 @@ async def test_persist_revives_orphaned_approved_device(db_session) -> None:
)
).scalar_one()
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
@@ -445,3 +457,143 @@ async def test_persist_keeps_hidden_hidden(db_session) -> None:
)
).scalar_one()
assert still_hidden.status == "hidden"
@pytest.mark.asyncio
async def test_persist_device_on_multiple_canvases(db_session) -> None:
"""Regression: a device on TWO designs (one Node each) must not crash
re-import with MultipleResultsFound props refresh on both nodes."""
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Design, Node
d1 = Design(name="d1")
d2 = Design(name="d2")
db_session.add_all([d1, d2])
await db_session.flush()
for d in (d1, d2):
db_session.add(Node(
label="Wall Plug", type="zwave_router", status="online",
check_method="none", ieee_address="zwave-0xh-2", services=[],
properties=[], design_id=d.id,
))
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
bumped[1]["model"] = "ZW200"
# Must not raise.
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
).scalars().all()
assert len(nodes) == 2 # both canvas placements preserved
for n in nodes:
model = {p["key"]: p["value"] for p in n.properties}.get("Model")
assert model == "ZW200" # refreshed on every canvas
# ---------------------------------------------------------------------------
# /api/v1/zwave/config + /sync-now (auto-sync)
# ---------------------------------------------------------------------------
from unittest.mock import AsyncMock # noqa: E402
from app.core.config import settings # noqa: E402
@pytest.fixture
def _restore_zwave_env():
"""Snapshot + restore the env-only Z-Wave connection/activation settings."""
keys = (
"zwave_mqtt_host", "zwave_mqtt_port", "zwave_mqtt_username",
"zwave_mqtt_password", "zwave_prefix", "zwave_gateway_name",
"zwave_sync_enabled", "zwave_sync_interval",
)
saved = {k: getattr(settings, k) for k in keys}
yield
for k, v in saved.items():
setattr(settings, k, v)
@pytest.mark.asyncio
async def test_config_omits_credentials(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
settings.zwave_mqtt_username = "user"
settings.zwave_mqtt_password = "supersecret"
res = await client.get("/api/v1/zwave/config", headers=headers)
assert res.status_code == 200
assert "supersecret" not in res.text
assert "user" not in res.text
assert res.json()["host_configured"] is True
@pytest.mark.asyncio
async def test_config_requires_auth(client: AsyncClient) -> None:
res = await client.get("/api/v1/zwave/config")
assert res.status_code == 401
@pytest.mark.asyncio
async def test_enable_sync_without_host_rejected(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post(
"/api/v1/zwave/config",
json={"sync_enabled": True, "sync_interval": 600},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_save_config_persists_only_sync_fields(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
saved: dict = {}
with patch.object(type(settings), "save_overrides", lambda self: saved.update(
host=self.zwave_mqtt_host, enabled=self.zwave_sync_enabled,
interval=self.zwave_sync_interval,
)), patch("app.api.routes.zwave.set_zwave_sync_enabled"), \
patch("app.api.routes.zwave.reschedule_zwave_sync"):
res = await client.post(
"/api/v1/zwave/config",
json={"mqtt_host": "attacker", "sync_enabled": True, "sync_interval": 900},
headers=headers,
)
assert res.status_code == 200
assert settings.zwave_mqtt_host == "broker" # body host ignored
assert saved == {"host": "broker", "enabled": True, "interval": 900}
@pytest.mark.asyncio
async def test_sync_now_creates_scan_run(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = "broker"
with patch("app.api.routes.zwave._background_zwave_import", new_callable=AsyncMock):
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 200
data = res.json()
assert data["kind"] == "zwave"
assert data["status"] == "running"
@pytest.mark.asyncio
async def test_sync_now_rejected_without_host(
client: AsyncClient, headers: dict, _restore_zwave_env
) -> None:
settings.zwave_mqtt_host = ""
res = await client.post("/api/v1/zwave/sync-now", headers=headers)
assert res.status_code == 400
@pytest.mark.asyncio
async def test_sync_now_requires_auth(client: AsyncClient) -> None:
res = await client.post("/api/v1/zwave/sync-now")
assert res.status_code == 401
+176
View File
@@ -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 |
---
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "2.6.1",
"version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "2.6.1",
"version": "3.0.0",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "2.6.1",
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
+131 -17
View File
@@ -2,12 +2,13 @@ import { useEffect, useCallback, useRef, useState } from 'react'
import { ReactFlowProvider, type Connection, type Edge } from '@xyflow/react'
import { type Node } from '@xyflow/react'
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 { getCenteredPosition } from '@/utils/viewportCenter'
import { resolveVirtualEdgeParent } from '@/utils/virtualEdgeParent'
import { generateMarkdownTable } from '@/utils/exportMarkdown'
import { copyToClipboard } from '@/utils/clipboard'
import { getDesignIdFromUrl, setDesignIdInUrl } from '@/utils/designUrl'
import { ExportModal } from '@/components/modals/ExportModal'
import { exportCanvasToYaml, downloadYaml } from '@/utils/exportYaml'
import { parseYamlToCanvas } from '@/utils/importYaml'
@@ -25,9 +26,11 @@ import { ScanConfigModal } from '@/components/modals/ScanConfigModal'
import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
import { ProxmoxImportModal } from '@/components/proxmox/ProxmoxImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { CustomStyleModal } from '@/components/modals/CustomStyleModal'
import { SearchModal } from '@/components/modals/SearchModal'
import { PendingDevicesModal } from '@/components/modals/PendingDevicesModal'
import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
@@ -41,14 +44,16 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData'
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 { 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'
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, markSaved, markUnsaved, 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 { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -57,6 +62,7 @@ export default function App() {
useStatusPolling()
const [themeModalOpen, setThemeModalOpen] = useState(false)
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const [scanHistoryOpen, setScanHistoryOpen] = useState(false)
const [pendingModalOpen, setPendingModalOpen] = useState(false)
@@ -82,6 +88,7 @@ export default function App() {
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
const [proxmoxImportOpen, setProxmoxImportOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies
@@ -91,6 +98,7 @@ export default function App() {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) {
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 })
markSaved()
toast.success('Canvas saved')
@@ -98,7 +106,9 @@ export default function App() {
}
const nodesToSave = nodes.map(serializeNode)
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()
toast.success('Canvas saved')
return true
@@ -106,7 +116,7 @@ export default function App() {
toast.error('Save failed')
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
const handleSaveRef = useRef(handleSave)
@@ -122,19 +132,27 @@ export default function App() {
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const rfNodes = (apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap))
const rfEdges = (apiEdges as ApiEdge[]).map(deserializeApiEdge)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
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)
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
} catch {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle])
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
@@ -143,17 +161,26 @@ export default function App() {
if (saved && saved.nodes.length > 0) {
if (saved.theme_id) setTheme(saved.theme_id)
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 {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle])
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
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) {
const designs = standaloneStorage.ensureSeed()
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) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
@@ -164,7 +191,8 @@ export default function App() {
const res = await designsApi.list()
const loadedDesigns = res.data
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) {
setActiveDesign(targetId)
await loadCanvasFromApi(targetId)
@@ -239,6 +267,11 @@ export default function App() {
}
}, [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
const undoRef = useRef(undo)
const redoRef = useRef(redo)
@@ -622,6 +655,69 @@ export default function App() {
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) => {
setPendingConnection(connection)
}, [])
@@ -697,6 +793,7 @@ export default function App() {
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onZwaveImport={() => setZwaveImportOpen(true)}
onProxmoxImport={() => setProxmoxImportOpen(true)}
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
@@ -740,6 +837,7 @@ export default function App() {
onSubmit={handleAddNode}
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 }))}
onEditTypeStyle={setStyleEditorType}
/>
{/* key forces re-mount when editing a different node, resetting form state */}
@@ -769,6 +867,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 }))
})()}
currentNodeId={editNodeId ?? undefined}
onEditTypeStyle={setStyleEditorType}
/>
<EdgeModal
@@ -776,11 +875,6 @@ export default function App() {
open={!!pendingConnection}
onClose={() => setPendingConnection(null)}
onSubmit={handleEdgeConfirm}
initial={
pendingConnection?.sourceHandle?.includes('cluster') || pendingConnection?.targetHandle?.includes('cluster')
? { type: 'cluster' }
: undefined
}
/>
<EdgeModal
@@ -826,6 +920,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 && (
<ScanHistoryModal
open={scanHistoryOpen}
@@ -906,6 +1011,15 @@ export default function App() {
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
open={searchOpen}
onClose={() => setSearchOpen(false)}
+18
View File
@@ -229,4 +229,22 @@ describe('api/client', () => {
mod.zwaveApi.importToPending(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
View File
@@ -41,6 +41,17 @@ export const canvasApi = {
}) => 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 = {
create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
@@ -66,6 +77,26 @@ export interface 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 = {
trigger: (deepScan?: Partial<DeepScanConfig>) => api.post('/scan/trigger', deepScan ?? {}),
pending: () => api.get('/scan/pending'),
@@ -77,7 +108,7 @@ export const scanApi = {
approved: boolean
node_id: string
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),
hide: (id: string) => api.post(`/scan/pending/${id}/hide`),
ignore: (id: string) => api.post(`/scan/pending/${id}/ignore`),
@@ -87,8 +118,9 @@ export const scanApi = {
node_ids: string[]
device_ids: string[]
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_devices: SkippedDevice[]
}>('/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 }),
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),
}
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 = {
list: () => api.get<import('@/types').Design[]>('/designs'),
create: (data: { name: string; icon?: string; design_type?: string }) =>
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 }) =>
api.put<import('@/types').Design>(`/designs/${id}`, data),
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 = {
testConnection: (data: {
mqtt_host: string
@@ -163,6 +289,13 @@ export const zigbeeApi = {
finished_at: string | null
error: string | null
}>('/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 = {
@@ -212,4 +345,11 @@ export const zwaveApi = {
finished_at: string | null
error: string | null
}>('/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'),
}
+3 -2
View File
@@ -27,7 +27,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
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 { liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
@@ -88,10 +88,11 @@ function LiveViewCanvas() {
const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
loadCanvas(
const migrated = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
loadCanvas(migrated.nodes, migrated.edges)
setViewState('ready')
})
.catch((err) => {
@@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides'
import { FloorMapLayer } from './FloorMapLayer'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import { setViewportCenterProjector } from '@/utils/viewportCenter'
import type { NodeData, EdgeData } from '@/types'
@@ -191,6 +192,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
colorMode={theme.colors.reactFlowColorMode}
elevateNodesOnSelect={false}
connectionMode={ConnectionMode.Loose}
connectionRadius={30}
isValidConnection={isValidConnection}
>
<Background
@@ -199,6 +201,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1}
color={theme.colors.canvasDotColor}
/>
<FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} />
<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>
)
}
@@ -9,7 +9,7 @@ let mockZoom = 1
vi.mock('@xyflow/react', () => ({
Handle: () => null,
Position: { Top: 'top', Bottom: 'bottom' },
Position: { Top: 'top', Bottom: 'bottom', Left: 'left', Right: 'right' },
NodeResizer: () => null,
useUpdateNodeInternals: () => vi.fn(),
useViewport: () => ({ zoom: mockZoom }),
@@ -59,14 +59,9 @@ vi.mock('@/utils/propertyIcons', () => ({
resolvePropertyIcon: (icon: string | null) => icon ? Server : null,
}))
vi.mock('@/utils/handleUtils', () => ({
bottomHandleId: (idx: number) => idx === 0 ? 'bottom' : `bottom-${idx + 1}`,
bottomHandlePositions: (count: number) => {
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,
}))
// handleUtils is pure — use the real implementation so the side-generic API
// (SIDES, handleId, handlePositions, sideHandleCount, …) stays in sync.
vi.mock('@/utils/handleUtils', async (importOriginal) => await importOriginal())
beforeEach(() => { mockZoom = 1 })
@@ -124,6 +119,14 @@ describe('BaseNode — borderWidth zoom scaling', () => {
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', () => {
mockZoom = 0.5
const node = makeNode({ status: 'online' })
@@ -176,15 +179,23 @@ describe('BaseNode — properties rendering', () => {
})
})
describe('BaseNode — port numbers (issue #20)', () => {
it('renders a number above each bottom handle when show_port_numbers is on', () => {
describe('BaseNode — port numbers (issue #20 / #243)', () => {
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 })
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('3')).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', () => {
renderBaseNode({ bottom_handles: 4 })
expect(screen.queryByText('1')).toBeNull()
@@ -192,8 +203,9 @@ describe('BaseNode — port numbers (issue #20)', () => {
})
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 })
expect(screen.getByText('1')).toBeDefined()
expect(screen.getAllByText('1')).toHaveLength(2)
expect(screen.getByText('2')).toBeDefined()
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')
})
})
@@ -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')
})
})
+80 -2
View File
@@ -9,10 +9,12 @@ import {
type EdgeProps,
type Edge,
} from '@xyflow/react'
import type { EdgeData, EdgeType, Waypoint } from '@/types'
import type { EdgeData, EdgeLineStyle, EdgeType, Waypoint } from '@/types'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
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'
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]
}
/** 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 ─────────────────────────────────────────────────────
interface WaypointHandleProps {
@@ -333,8 +351,23 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
: customColor
?? (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 = {
...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) } : {}),
...(customColor ? { stroke: customColor } : {}),
...(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)
: []
// ── 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 (
<>
<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' && (
<path
@@ -1,5 +1,5 @@
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 type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
@@ -10,7 +10,8 @@ import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { useCanvasStore, serviceStatusKey } from '@/stores/canvasStore'
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'
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) {
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 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 &&
(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 (
<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={{
background: colors.background,
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`
: 'none',
opacity: data.status === 'offline' ? 0.55 : 1,
// Grow node width when many bottom handles so each stays clickable (~14px slot).
minWidth: Math.max(140, clampBottomHandles(data.bottom_handles ?? 1) * 14),
// Grow node so each handle stays clickable (~14px slot on each axis).
minWidth: Math.max(140, Math.max(topCount, bottomCount) * 14),
minHeight: Math.max(50, Math.max(leftCount, rightCount) * 14),
width: width ? '100%' : undefined,
height: height ? '100%' : undefined,
}}
@@ -73,15 +81,16 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
minWidth={140}
minHeight={50}
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
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
<SideHandles
data={data}
sides={['top', 'left', 'right']}
handleBackground={theme.colors.handleBackground}
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 */}
<div
@@ -251,40 +260,14 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
</>
)}
{bottomHandlePositions(data.bottom_handles ?? 1).map((leftPct, idx) => {
const sourceId = bottomHandleId(idx)
const targetId = `${sourceId}-t`
return (
<span key={sourceId}>
{data.show_port_numbers && (
<span
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>
)
})}
<SideHandles
data={data}
sides={['bottom']}
handleBackground={theme.colors.handleBackground}
handleBorder={theme.colors.handleBorder}
labelColor={theme.colors.nodeSubtextColor}
showLabels
/>
</div>
)
}
@@ -1,5 +1,5 @@
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 type { NodeData } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
@@ -10,47 +10,31 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { maskIp, splitIps } from '@/utils/maskIp'
import { useThemeStore } from '@/stores/themeStore'
import { THEMES } from '@/utils/themes'
import { bottomHandleId, bottomHandlePositions } from '@/utils/handleUtils'
import { BaseNode } from './BaseNode'
import { SideHandles } from './SideHandles'
export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
const { id, data, selected } = props
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 hideIp = useCanvasStore((s) => s.hideIp)
const theme = THEMES[activeTheme]
const colors = resolveNodeColors(data, activeTheme)
// Render as a regular node when container mode is disabled
if (data.container_mode === false) {
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
return (
<>
<BaseNode {...props} icon={Layers} />
<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 }}
/>
</>
)
// Container mode is opt-in — a proxmox node renders as a regular card unless
// it is explicitly a container (matches the rest of the codebase, which gates
// nesting on `container_mode === true`; see App.tsx). Imported nodes leave the
// flag unset and so render like a manually-created proxmox node. Cluster links
// use the configurable per-side connection points (see BaseNode / SideHandles).
if (data.container_mode !== true) {
return <BaseNode {...props} icon={Layers} />
}
const statusColor = theme.colors.statusColors[data.status]
const isOnline = data.status === 'online'
const glow = colors.border
const proxmoxAccent = theme.colors.nodeAccents.proxmox.border
const resolvedIcon = resolveNodeIcon(Layers, data.custom_icon)
return (
@@ -145,48 +129,11 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
<div className="flex-1 relative" />
</div>
<Handle
type="source"
position={Position.Top}
id="top"
style={{ background: theme.colors.handleBackground, borderColor: theme.colors.handleBorder }}
/>
<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 }}
<SideHandles
data={data}
handleBackground={theme.colors.handleBackground}
handleBorder={theme.colors.handleBorder}
labelColor={theme.colors.nodeSubtextColor}
/>
</>
)
@@ -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',
status: 'online',
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,
}
const props = {
@@ -51,7 +54,7 @@ describe('ProxmoxGroupNode', () => {
})
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()
})
@@ -90,14 +93,21 @@ describe('ProxmoxGroupNode', () => {
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 })
// NodeResizer should not be present when not group-rendered
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
// The group container uses rounded-xl border-2; BaseNode does not.
expect(container.querySelector('.rounded-xl.border-2')).toBeNull()
})
it('container_mode default renders the group border container', () => {
const { container } = renderNode({})
it('container mode is opt-in: default (unset) renders as a regular BaseNode', () => {
// 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
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
})
@@ -114,10 +124,20 @@ describe('ProxmoxGroupNode', () => {
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({})
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 })
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 { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { clampHandles, sideDefault } from '@/utils/handleUtils'
import { THEMES } from '@/utils/themes'
import { applyOpacity } from '@/utils/colorUtils'
import type {
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle,
NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, EdgePathStyle, EdgeLineStyle,
} 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) ──
@@ -62,10 +68,41 @@ function defaultEdgeStyle(edgeType: EdgeType): EdgeTypeStyle {
color: THEMES.default.colors.edgeColors[edgeType],
opacity: 1,
pathStyle: 'bezier',
lineStyle: EDGE_TYPE_DEFAULT_LINE[edgeType],
widthMult: 1,
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 ──────────────────────────────────────────────────────
interface ColorRowProps {
@@ -178,6 +215,33 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
</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 (064 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
size="sm"
className="self-start bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90"
@@ -217,6 +281,52 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
</div>
<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 className="text-xs text-[#8b949e] mb-2">Path style</div>
<div className="flex gap-2">
@@ -251,6 +361,14 @@ function EdgeEditor({ edgeType, style, onChange, onApplyToExisting }: EdgeEditor
<option value="snake">Snake</option>
</select>
</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>
<Button
@@ -272,9 +390,11 @@ type Selection = { kind: 'node'; type: NodeType } | { kind: 'edge'; type: EdgeTy
interface CustomStyleModalProps {
open: boolean
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 { markUnsaved, applyTypeNodeStyle, applyTypeEdgeStyle, applyAllCustomStyles } = useCanvasStore()
@@ -292,7 +412,12 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
useEffect(() => {
if (open) {
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
// clobber an in-progress edit, only a fresh open should reset.
@@ -417,6 +542,8 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
const swatchColor = style
? applyOpacity(style.color, style.opacity)
: THEMES.default.colors.edgeColors[t]
const lineStyle = style?.lineStyle ?? EDGE_TYPE_DEFAULT_LINE[t]
const widthMult = clampWidthMult(style?.widthMult)
return (
<button
@@ -430,9 +557,10 @@ export function CustomStyleModal({ open, onClose }: CustomStyleModalProps) {
}}
>
<span className="flex-1 truncate">{EDGE_TYPE_LABELS[t]}</span>
<span
className="w-8 h-1.5 rounded-full shrink-0"
style={{ background: swatchColor }}
<EdgeLineSwatch
color={swatchColor}
lineStyle={lineStyle}
strokeWidth={EDGE_TYPE_BASE_WIDTH[t] * widthMult}
/>
</button>
)
+227 -5
View File
@@ -1,13 +1,25 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
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 {
name: 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 {
@@ -17,21 +29,106 @@ interface DesignModalProps {
initial?: DesignFormData
title?: 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 [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 trimmed = name.trim()
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 (
<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>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
@@ -75,6 +172,131 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can
})}
</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>
<DialogFooter>
+80 -1
View File
@@ -7,8 +7,14 @@ import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
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 { 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][]
@@ -38,8 +44,15 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
const [customColor, setCustomColor] = useState<string | undefined>(initial?.custom_color)
const [pathStyle, setPathStyle] = useState<EdgePathStyle>(initial?.path_style ?? 'bezier')
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 effectiveLineStyle = lineStyle ?? EDGE_TYPE_DEFAULT_LINE[type]
const previewWidth = EDGE_TYPE_BASE_WIDTH[type] * widthMult
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
@@ -49,7 +62,11 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor,
path_style: pathStyle,
line_style: effectiveLineStyle,
width_mult: widthMult,
animated: animation !== 'none' ? animation : undefined,
marker_start: markerStart,
marker_end: markerEnd,
})
onClose()
}
@@ -130,6 +147,60 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
</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">
<Label className="text-xs text-muted-foreground">Animation</Label>
<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 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 items-center justify-between">
<Label className="text-xs text-muted-foreground">Color</Label>
@@ -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>
)
}
+153 -36
View File
@@ -1,16 +1,18 @@
import { Fragment, createElement, useState } from 'react'
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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { 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 { ICON_REGISTRY, ICON_CATEGORIES, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
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'
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'] },
]
// 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 CONTAINER_MODE_TYPES: NodeType[] = ['proxmox', 'vm', 'lxc', 'docker_host']
const ZIGBEE_TYPES: NodeType[] = ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice']
@@ -71,11 +132,13 @@ interface NodeModalProps {
title?: string
parentCandidates?: ParentCandidate[]
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
// 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 }
if (MESH_TYPES.includes((merged.type ?? '') as NodeType)) merged.check_method = 'none'
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) =>
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) => {
e.preventDefault()
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)
if (!parent || !isValidParent(parent)) safeParentId = undefined
}
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
onSubmit({
...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,
container_mode: canUseContainerMode ? !!form.container_mode : false,
})
@@ -123,13 +205,17 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
return (
<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>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<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 */}
<div className="flex flex-col gap-1.5">
<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' }} />
</button>
</div>
</div>{/* end Type/Icon subgrid */}
{/* Inline icon picker - full width, shown below the type+icon row */}
{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>}
</div>
<div className="grid grid-cols-2 gap-3">
{/* Hostname */}
<div className="flex flex-col gap-1.5">
<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>
</div>
</div>{/* end Hostname/IP subgrid */}
<div className="grid grid-cols-2 gap-3">
{/* Check method — hidden for zigbee nodes (always none/online) */}
{!ZIGBEE_TYPES.includes((form.type ?? '') as NodeType) && (
<div className="flex flex-col gap-1.5">
@@ -357,6 +447,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
/>
</div>
)}
</div>{/* end Check method/target subgrid */}
{/* Parent Container */}
{(() => {
@@ -423,7 +514,22 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</button>
</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 */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<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>
)}
</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>
{/* Bottom connection points (not for group containers) */}
{/* Connection points per side (not for group containers) */}
{form.type !== 'groupRect' && form.type !== 'group' && (
<div className="flex flex-col gap-1.5 col-span-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Bottom Connection Points</Label>
<span className="text-xs font-mono text-foreground">{clampBottomHandles(form.bottom_handles ?? 1)}</span>
</div>
<input
type="range"
min={MIN_BOTTOM_HANDLES}
max={MAX_BOTTOM_HANDLES}
step={1}
value={clampBottomHandles(form.bottom_handles ?? 1)}
onChange={(e) => set('bottom_handles', clampBottomHandles(Number(e.target.value)))}
aria-label="Bottom connection points slider"
className="w-full accent-[#00d4ff] cursor-pointer"
/>
<div className="flex justify-between text-[10px] text-muted-foreground/60 font-mono">
<span>{MIN_BOTTOM_HANDLES}</span>
<span>{MAX_BOTTOM_HANDLES}</span>
<div className="flex flex-col gap-2.5 col-span-2">
<Label className="text-xs text-muted-foreground">Connection Points</Label>
{/* Spatial cross: each side's stepper sits where that side is. */}
<div className="grid grid-cols-[1fr_auto_1fr] items-center justify-items-center gap-x-2 gap-y-2 py-1">
<div />
<CPStepper label="Top" side="top" value={sideValue('top')}
onChange={(v) => set('top_handles', v)} />
<div />
<CPStepper label="Left" side="left" value={sideValue('left')}
onChange={(v) => set('left_handles', v)} />
<div
className="flex items-center justify-center rounded-md border text-[9px] uppercase tracking-wide font-medium select-none"
style={{
width: 64, height: 40,
borderColor: resolvedNodeColors.border,
background: `${resolvedNodeColors.background}`,
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 className="flex items-center justify-between pt-1">
<div className="flex flex-col gap-0.5">
<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>
<button
type="button"
@@ -554,16 +680,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
</div>
)}
{/* Notes */}
<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>{/* ── end RIGHT column ── */}
</div>
<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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import type { NodeProperty } from '@/types'
interface Service {
port: number
@@ -20,12 +21,18 @@ export interface PendingDevice {
suggested_type: string | null
status: string
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
friendly_name?: string | null
device_subtype?: string | null
model?: string | null
vendor?: string | null
lqi?: number | null
// Display properties carried from discovery (e.g. Proxmox specs).
properties?: NodeProperty[]
discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number
@@ -4,17 +4,21 @@ import {
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
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 { useDesignStore } from '@/stores/designStore'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { toast } from 'sonner'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
import { applyAutoEdges, type AutoEdge } from '@/utils/autoEdges'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { getCenteredPosition } from '@/utils/viewportCenter'
import { sourceBuckets, orderedSources, SOURCE_META, type SourceBucket } from '@/utils/pendingSources'
interface PendingDevicesModalProps {
open: boolean
@@ -71,16 +75,9 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
generic: Circle,
}
type SourceFilter = 'all' | 'ip' | 'zigbee' | 'zwave'
type SourceFilter = 'all' | SourceBucket
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])
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'
}
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
useCanvasStore.setState((state) => ({
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 },
})),
],
...applyAutoEdges(state.nodes, state.edges, edges),
hasUnsavedChanges: true,
}))
}
@@ -130,8 +128,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
// Optionally restrict to devices that have at least one detected service.
const [withServicesOnly, setWithServicesOnly] = useState(false)
const { addNode, scanEventTs } = useCanvasStore()
const setSelectedNode = useCanvasStore((s) => s.setSelectedNode)
const activeDesignId = useDesignStore((s) => s.activeDesignId)
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 () => {
setLoading(true)
@@ -168,7 +170,7 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
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
// Inventory-only: optionally hide devices already placed on a canvas.
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 {
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)
: 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 res = await scanApi.approve(device.id, { ...nodeData, force })
const nodeId = res.data.node_id
addNode({
id: nodeId,
@@ -301,13 +305,35 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
injectAutoEdges(res.data.edges)
const extra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${nodeData.label}${extra}`)
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)
} 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')
}
}
const handleApprove = (device: PendingDevice) => approveDevice(device, false)
const goToExistingNode = (nodeId: string) => {
setSelectedNode(nodeId)
setDupPrompt(null)
setSelected(null)
onClose()
}
const handleHide = async (device: PendingDevice) => {
try {
await scanApi.hide(device.id)
@@ -362,15 +388,29 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
? buildZwaveProperties(d)
: isZigbeeType(type)
? buildZigbeeProperties(d)
: buildMacProperty(d.mac),
: [...(d.properties ?? []), ...buildMacProperty(d.mac)],
},
})
})
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())
await load()
const linkExtra = res.data.edges_created > 0 ? ` (+${res.data.edges_created} link${res.data.edges_created !== 1 ? 's' : ''})` : ''
toast.success(`Approved ${res.data.approved} device${res.data.approved !== 1 ? 's' : ''}${linkExtra}`)
// 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 {
toast.error('Failed to bulk approve devices')
}
@@ -501,6 +541,12 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
>
Z-Wave
</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>
<select
value={typeFilter}
@@ -632,6 +678,59 @@ export function PendingDevicesModal({ open, onClose, highlightId, initialStatus
)}
</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>
</Dialog>
@@ -656,14 +755,14 @@ interface DeviceCardProps {
}
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
const source = inferSource(device)
const Icon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const sources = orderedSources(device)
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 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 visibleServices = services.slice(0, 4)
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="text-sm font-medium text-foreground break-all leading-snug">{label}</div>
<div className="flex items-center gap-1 mt-0.5 flex-wrap">
<span
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${sourceColor}22`, color: sourceColor }}
>
{sourceLabel}
</span>
{sources.map((s) => (
<span
key={s}
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${SOURCE_META[s].color}22`, color: SOURCE_META[s].color }}
>
{SOURCE_META[s].label}
</span>
))}
{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}
</span>
)}
@@ -1,5 +1,5 @@
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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { scanApi } from '@/api/client'
@@ -22,17 +22,21 @@ interface ScanHistoryModalProps {
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. */
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' {
return kind === 'zigbee' ? 'zigbee' : kind === 'zwave' ? 'zwave' : 'ip'
function runKind(kind: string | undefined): 'ip' | 'zigbee' | 'zwave' | 'proxmox' {
return kind === 'zigbee' ? 'zigbee'
: kind === 'zwave' ? 'zwave'
: kind === 'proxmox' ? 'proxmox'
: 'ip'
}
const KIND_META = {
ip: { label: 'IP', color: '#a855f7' },
zigbee: { label: 'Zigbee', color: '#00d4ff' },
zwave: { label: 'Z-Wave', color: '#ff6e00' },
proxmox: { label: 'Proxmox', color: '#e57000' },
} as const
type StatusFilter = 'all' | 'running' | 'done' | 'error' | 'cancelled'
@@ -49,6 +53,7 @@ const KIND_FILTERS: { key: KindFilter; label: string }[] = [
{ key: 'ip', label: 'IP' },
{ key: 'zigbee', label: 'Zigbee' },
{ key: 'zwave', label: 'Z-Wave' },
{ key: 'proxmox', label: 'Proxmox' },
]
function statusColor(s: string): string {
@@ -102,9 +107,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
toast.error(`Scan failed: ${run.error ?? 'unknown error'}`)
}
if (prev?.status === 'running' && run.status === 'done') {
if (run.kind === 'zigbee' || run.kind === 'zwave') {
const label = run.kind === 'zwave' ? 'Z-Wave' : 'Zigbee'
toast.success(`${label} import done — ${run.devices_found} device${run.devices_found !== 1 ? 's' : ''}`)
if (run.kind === 'zigbee' || run.kind === 'zwave' || run.kind === 'proxmox') {
const label = run.kind === 'zwave' ? 'Z-Wave' : run.kind === 'proxmox' ? 'Proxmox' : 'Zigbee'
// 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()
}
@@ -231,7 +242,7 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
{filtered.map((r) => {
const kind = runKind(r.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 (
<div key={r.id} className="rounded-lg border border-border bg-[#161b22] p-3">
<div className="flex items-center gap-2">
@@ -286,7 +297,15 @@ export function ScanHistoryModal({ open, onClose }: ScanHistoryModalProps) {
)}
{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}
</div>
)}
@@ -1,7 +1,15 @@
import { useState, useEffect } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
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 { toast } from 'sonner'
import {
@@ -18,11 +26,103 @@ interface SettingsModalProps {
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) {
const [interval, setIntervalValue] = useState(60)
const [serviceCheckEnabled, setServiceCheckEnabled] = useState(false)
const [serviceInterval, setServiceInterval] = useState(300)
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 hideIp = useCanvasStore((s) => s.hideIp)
const setHideIp = useCanvasStore((s) => s.setHideIp)
@@ -36,6 +136,27 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
setServiceInterval(res.data.service_check_interval)
})
.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])
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
@@ -46,6 +167,42 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
writeAlignmentSettings(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 () => {
// Canvas prefs (alignment, hide-IP) persist on change; only the backend
// status-check interval needs an API round-trip.
@@ -60,6 +217,27 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
service_check_enabled: serviceCheckEnabled,
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')
onClose()
} catch {
@@ -71,12 +249,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
return (
<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>
<DialogTitle className="text-foreground">Settings</DialogTitle>
</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 */}
{!STANDALONE && (
<div className="space-y-1.5">
@@ -174,6 +354,107 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
</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>
<DialogFooter className="gap-2">
@@ -4,7 +4,7 @@ import { CustomStyleModal } from '../CustomStyleModal'
import { useThemeStore } from '@/stores/themeStore'
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'
describe('CustomStyleModal', () => {
@@ -54,6 +54,13 @@ describe('CustomStyleModal', () => {
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', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
@@ -117,6 +124,41 @@ describe('CustomStyleModal', () => {
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', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
@@ -135,6 +177,34 @@ describe('CustomStyleModal', () => {
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)', () => {
// Parent keeps the modal mounted and only toggles `open`, so the reset must
// happen on the open-prop edge, not via Radix onOpenChange.
@@ -64,4 +64,199 @@ describe('DesignModal', () => {
expect(onClose).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')
})
// ── 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 ──────────────────────────────────────────────────────
it('animation defaults to None — animated omitted from payload', () => {
@@ -165,6 +200,58 @@ describe('EdgeModal', () => {
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', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -105,6 +105,21 @@ describe('NodeModal', () => {
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 ──────────────────────────────────────────────────
it('blocks submit and shows error when label is empty', () => {
@@ -435,57 +450,88 @@ describe('NodeModal', () => {
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 })
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' } })
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' } })
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 })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('1')
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).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', () => {
renderModal({ initial: { ...BASE, bottom_handles: 3 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('3')
it('pre-fills each side from initial', () => {
renderModal({ initial: { ...BASE, top_handles: 2, bottom_handles: 3, left_handles: 4, right_handles: 1 } })
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).value).toBe('2')
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 slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
fireEvent.change(slider, { target: { value: '12' } })
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '12' } })
fireEvent.change(screen.getByLabelText('Left connection points'), { target: { value: '3' } })
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 slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.min).toBe('1')
expect(slider.max).toBe('64')
fireEvent.change(slider, { target: { value: '52' } })
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
fireEvent.click(screen.getByRole('button', { name: 'Increase Right connection points' }))
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
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' }))
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 } })
const slider = screen.getByLabelText('Bottom connection points slider') as HTMLInputElement
expect(slider.value).toBe('64')
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).value).toBe('64')
})
it('toggles show_port_numbers and submits it (issue #20)', () => {
@@ -14,6 +14,7 @@ const mockHide = vi.fn()
const mockPending = vi.fn()
const mockHidden = vi.fn()
const mockAddNode = vi.fn()
const mockSetSelectedNode = vi.fn()
vi.mock('@/api/client', () => ({
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', () => ({
PendingDeviceModal: ({ device }: { device: unknown }) =>
device ? <div data-testid="approval-modal" /> : null,
PendingDeviceModal: ({ device, onApprove }: { device: unknown; onApprove: (d: unknown) => void }) =>
device ? (
<div data-testid="approval-modal">
<button data-testid="do-approve" onClick={() => onApprove(device)}>approve</button>
</div>
) : null,
}))
const DEVICE_IP = {
@@ -84,12 +89,32 @@ const DEVICE_ZWAVE = {
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(() => {
vi.clearAllMocks()
vi.mocked(useCanvasStore).mockReturnValue({
addNode: mockAddNode,
scanEventTs: 0,
} as unknown as ReturnType<typeof useCanvasStore>)
// Apply the selector when one is passed (setSelectedNode is read via a
// selector), else return the whole store (destructured in the component).
vi.mocked(useCanvasStore).mockImplementation(((sel?: (s: unknown) => unknown) => {
const store = { addNode: mockAddNode, scanEventTs: 0, setSelectedNode: mockSetSelectedNode }
return sel ? sel(store) : store
}) as unknown as typeof useCanvasStore)
// setState is used by injectAutoEdges
;(useCanvasStore as unknown as { setState: (fn: unknown) => void }).setState = vi.fn()
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZIGBEE] })
@@ -97,7 +122,7 @@ beforeEach(() => {
mockApprove.mockResolvedValue({ data: { node_id: 'n1', edges: [], edges_created: 0 } })
mockHide.mockResolvedValue({ data: {} })
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 } })
mockRestore.mockResolvedValue({ data: { restored: true, device_id: 'dev-a' } })
@@ -153,6 +178,18 @@ describe('PendingDevicesModal', () => {
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 () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP, DEVICE_ZWAVE] })
render(<PendingDevicesModal {...baseProps} />)
@@ -162,6 +199,23 @@ describe('PendingDevicesModal', () => {
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 () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -188,6 +242,60 @@ describe('PendingDevicesModal', () => {
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 () => {
render(<PendingDevicesModal {...baseProps} />)
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))
})
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 () => {
render(<PendingDevicesModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument())
@@ -9,7 +9,7 @@ vi.mock('@/api/client', () => ({
trigger: vi.fn(),
},
}))
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
import { scanApi } from '@/api/client'
import { toast } from 'sonner'
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { ScanHistoryModal } from '../ScanHistoryModal'
import { TooltipProvider } from '@/components/ui/tooltip'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/stores/canvasStore', () => ({
useCanvasStore: { getState: () => ({ notifyScanDeviceFound: vi.fn() }) },
}))
@@ -72,6 +72,17 @@ const ZWAVE_RUN = {
error: null,
}
const PROXMOX_RUN = {
id: 'run-6',
status: 'done',
kind: 'proxmox',
ranges: ['pve:8006'],
devices_found: 9,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
error: null,
}
function renderModal() {
return render(
<TooltipProvider>
@@ -84,6 +95,7 @@ describe('ScanHistoryModal', () => {
beforeEach(() => {
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
vi.mocked(toast.warning).mockReset()
vi.mocked(scanApi.stop).mockReset()
vi.mocked(scanApi.runs).mockResolvedValue({ data: [] } as never)
})
@@ -176,4 +188,31 @@ describe('ScanHistoryModal', () => {
expect(screen.getByText('5 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
it('renders a done proxmox run with an advisory as info, not a failure', async () => {
const ADVISORY_RUN = {
...PROXMOX_RUN,
id: 'run-7',
devices_found: 3,
error: 'Imported 3 host(s) but no VMs or LXC were visible to the API token. Grant PVEAuditor…',
}
vi.mocked(scanApi.runs).mockResolvedValue({ data: [ADVISORY_RUN] } as never)
renderModal()
// Status stays "done" (success), yet the advisory text is surfaced.
await waitFor(() => expect(screen.getByText('done')).toBeDefined())
expect(screen.getByText(/no VMs or LXC were visible/)).toBeDefined()
})
it('shows a proxmox run under its own kind, not IP', async () => {
vi.mocked(scanApi.runs).mockResolvedValue({ data: [DONE_RUN, PROXMOX_RUN] } as never)
renderModal()
await waitFor(() => expect(screen.getAllByText('done').length).toBe(2))
// A dedicated Proxmox badge is rendered on the run (would be mislabeled "IP"
// before the fix). Both the filter chip and the run badge carry the label.
expect(screen.getAllByText('Proxmox').length).toBeGreaterThanOrEqual(2)
// Filtering to Proxmox keeps only the proxmox run (9 found), drops the IP run.
fireEvent.click(screen.getByRole('button', { name: 'Proxmox' }))
expect(screen.getByText('9 found')).toBeDefined()
expect(screen.queryByText('3 found')).toBeNull()
})
})
@@ -2,15 +2,30 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { SettingsModal } from '../SettingsModal'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
vi.mock('@/api/client', () => ({
settingsApi: {
get: vi.fn(),
save: vi.fn(),
},
proxmoxApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
zigbeeApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
zwaveApi: {
getConfig: vi.fn(),
saveConfig: vi.fn(),
syncNow: vi.fn(),
},
}))
import { settingsApi } from '@/api/client'
import { settingsApi, proxmoxApi, zigbeeApi, zwaveApi } from '@/api/client'
import { toast } from 'sonner'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -19,10 +34,27 @@ describe('SettingsModal', () => {
vi.clearAllMocks()
vi.mocked(settingsApi.get).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(settingsApi.save).mockResolvedValue({ data: { interval_seconds: 60, service_check_enabled: false, service_check_interval: 300 } } as never)
vi.mocked(proxmoxApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
// Zigbee/Z-Wave default to "not configured" so the mesh sections stay hidden
// unless a test opts in — keeps the single Proxmox "Re-sync now" unambiguous.
vi.mocked(zigbeeApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(zigbeeApi.saveConfig).mockResolvedValue({ data: {} } as never)
vi.mocked(zigbeeApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never)
vi.mocked(zwaveApi.getConfig).mockRejectedValue(new Error('not configured'))
vi.mocked(zwaveApi.saveConfig).mockResolvedValue({ data: {} } as never)
vi.mocked(zwaveApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never)
vi.mocked(toast.success).mockReset()
vi.mocked(toast.error).mockReset()
})
const zbConfig = (over = {}) => ({
data: { mqtt_host: 'broker', mqtt_port: 1883, base_topic: 'zigbee2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over },
})
const zwConfig = (over = {}) => ({
data: { mqtt_host: 'broker', mqtt_port: 1883, prefix: 'zwave', gateway_name: 'zwavejs2mqtt', mqtt_tls: false, sync_enabled: false, sync_interval: 3600, host_configured: true, ...over },
})
it('loads interval from API when opened', async () => {
render(<SettingsModal open onClose={vi.fn()} />)
await waitFor(() => expect(settingsApi.get).toHaveBeenCalledOnce())
@@ -92,6 +124,81 @@ describe('SettingsModal', () => {
})
})
it('persists only sync fields (not connection config) on Save', async () => {
vi.mocked(proxmoxApi.getConfig).mockResolvedValue({
data: { host: 'pve', port: 8006, verify_tls: true, sync_enabled: true, sync_interval: 3600, token_configured: true },
} as never)
vi.mocked(proxmoxApi.saveConfig).mockResolvedValue({ data: {} } as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(proxmoxApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 3600 })
})
})
it('triggers an immediate Proxmox sync from the Re-sync now button', async () => {
vi.mocked(proxmoxApi.getConfig).mockResolvedValue({
data: { host: 'pve', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600, token_configured: true },
} as never)
vi.mocked(proxmoxApi.syncNow).mockResolvedValue({ data: { status: 'running' } } as never)
render(<SettingsModal open onClose={vi.fn()} />)
const btn = await screen.findByRole('button', { name: 'Re-sync now' })
fireEvent.click(btn)
await waitFor(() => {
expect(proxmoxApi.syncNow).toHaveBeenCalledOnce()
expect(toast.success).toHaveBeenCalledWith('Proxmox sync started')
})
})
it('shows a PROXMOX_HOST hint instead of the button when host is unset', async () => {
vi.mocked(proxmoxApi.getConfig).mockResolvedValue({
data: { host: '', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600, token_configured: true },
} as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByText('PROXMOX_HOST')
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull()
})
it('hides Re-sync now when no Proxmox token is configured', async () => {
vi.mocked(proxmoxApi.getConfig).mockResolvedValue({
data: { host: 'pve', port: 8006, verify_tls: true, sync_enabled: false, sync_interval: 3600, token_configured: false },
} as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull()
})
it('persists only Zigbee sync fields (not connection config) on Save', async () => {
vi.mocked(zigbeeApi.getConfig).mockResolvedValue(zbConfig({ sync_enabled: true, sync_interval: 1800 }) as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByDisplayValue('60')
await screen.findByText('Zigbee auto-sync')
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(zigbeeApi.saveConfig).toHaveBeenCalledWith({ sync_enabled: true, sync_interval: 1800 })
})
})
it('triggers an immediate Z-Wave sync from its Re-sync now button', async () => {
vi.mocked(zwaveApi.getConfig).mockResolvedValue(zwConfig() as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByText('Z-Wave auto-sync')
const btn = await screen.findByRole('button', { name: 'Re-sync now' })
fireEvent.click(btn)
await waitFor(() => {
expect(zwaveApi.syncNow).toHaveBeenCalledOnce()
expect(toast.success).toHaveBeenCalledWith('Z-Wave sync started')
})
})
it('shows an env-var hint instead of the section controls when mesh host is unset', async () => {
vi.mocked(zigbeeApi.getConfig).mockResolvedValue(zbConfig({ host_configured: false, mqtt_host: '' }) as never)
render(<SettingsModal open onClose={vi.fn()} />)
await screen.findByText('ZIGBEE_MQTT_HOST')
expect(screen.queryByRole('button', { name: 'Re-sync now' })).toBeNull()
})
it('calls onClose on Cancel', async () => {
const onClose = vi.fn()
render(<SettingsModal open onClose={onClose} />)
@@ -5,7 +5,7 @@ import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
import { THEME_ORDER } from '@/utils/themes'
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'
describe('ThemeModal', () => {

Some files were not shown because too many files have changed in this diff Show More