Compare commits

...
32 Commits
Author SHA1 Message Date
Pouzor 7eec0b3f9a chore: bump version to 3.3.1
ha-relevant: no
2026-08-17 01:58:26 +02:00
Pouzor b76519b0dd fix(db): keep nodes insertable when the 3.3.0 backfill cannot finish
Upgrading 3.2.0 -> 3.3.0 could leave a database where approving a device —
or creating any node — failed with `NOT NULL constraint failed: nodes.status`.

The 3.3.0 migration moves the device facts off `nodes` onto `device_inventory`
and then drops the columns. The drop is skipped when a node is still unlinked,
because those columns are the only remaining copy of its facts. But a 3.2.0
`nodes` declares `status`, `services`, `properties` and `show_hardware` NOT NULL
with no server-side default, and the 3.3.0 model no longer writes them — so a
skipped drop bricks every later INSERT.

It skipped because one node killed the whole backfill: the loop shared a single
session, so an IntegrityError raised by autoflush lost every link made before it.

- Give each node its own savepoint. A node whose merge violates a constraint is
  skipped and logged on its own; the rest still link. `skipped` joins the stats
  reported at boot.
- Relax NOT NULL on the retained legacy columns when the drop is skipped, so the
  database stays writable while later boots retry the backfill. Values are kept;
  only the constraint goes. Guarded on the PRAGMA notnull flag, so it runs once.
- Share one `_rebuild_nodes` between the drop and the relax: it restores
  `PRAGMA foreign_keys = ON` in a finally (the connection returns to the pool)
  and clears a `nodes_new` left by a failed attempt.
- Match IEEE addresses case-insensitively, and never write one another inventory
  row already owns — `device_inventory.ieee_address` is UNIQUE, and a duplicate
  is one way the backfill was raising in the first place.

Tests build the real v3.2.0 schema from the tag: the existing legacy-migration
fixture declares `status` nullable, which is why this reached a release.

Fixes #351

ha-relevant: no
2026-08-17 01:55:51 +02:00
Pouzor a2870a6e29 docs(changelog): match the house style for contributor credit
ha-relevant: no
2026-08-15 02:48:06 +02:00
Pouzor d6308a5573 chore: bump version to 3.3.0
ha-relevant: no
2026-08-15 02:39:49 +02:00
Pouzor fafbaeffcd feat(sidebar): link the version row to the public changelog
ha-relevant: maybe
2026-08-15 02:32:40 +02:00
Pouzor 824aa5d044 docs(install): document the pre-built GHCR images
The quick starts already pull ready-made images through install.sh, but
nothing said so — issue #310 asked for something that already ships.

Add a "Pre-built Docker images" section to INSTALLATION.md listing the
four GHCR images, their tag scheme and a manual docker-compose.prebuilt.yml
setup, and note that the root docker-compose.yml builds from source.

Closes #310

ha-relevant: no
2026-08-15 02:18:57 +02:00
Pouzor 416b5fd1cb feat(properties): make the property value optional
A property may now carry a label alone: the add and edit forms require
only the key, and every surface that prints one — the badge list, node
and Proxmox group renderers, rack cable annotations — drops the "· value"
separator when the value is empty.

ha-relevant: yes
2026-08-15 02:10:02 +02:00
Pouzor fcc2927b69 feat(canvas): let a zone parent the nodes dropped inside it
Dropping a node onto a groupRect zone now asks to add it to the zone and
sets a real React Flow parentId, so moving the zone moves its contents.

Zone children are deliberately not extent-clamped, unlike group and
container children: a zone has no side panel to release a child from, so
dragging the node back out of the zone is what detaches it.

Deleting a zone now releases its children onto the canvas in absolute
coordinates instead of cascade-deleting them; groups and containers keep
the cascade. Collapse counts and hides both parented and merely
overlapping nodes, and the deserializer restores zone parenting on load.

ha-relevant: yes
2026-08-14 20:09:11 +02:00
Pouzor be46c21dd4 fix(deps): bump undici and ip-address to patch high severity advisories
Resolves the two high Dependabot alerts on the frontend lockfile:

- undici 7.28.0 -> 7.29.0 (transitive via jsdom): cross-user information
  disclosure and parse-time crash via degenerate private cache directives
- ip-address 10.2.0 -> 10.5.0 (transitive via shadcn ->
  @modelcontextprotocol/sdk -> express-rate-limit): leading-zero octets
  decoded as decimal, allowing SSRF and trust-boundary bypass

Also closes the moderate advisories on the same two packages. Lockfile only,
both are dev dependencies.

ha-relevant: no
2026-08-14 18:36:36 +02:00
Pouzor 1c9d5791b7 fix(inventory): resolve the remaining PR review findings
Three unrelated fixes from the review of this branch.

`create_pending` merging into a hidden row left it hidden, so adding a device
by hand whose IP collides with one hidden weeks ago answered 201 while nothing
appeared in the inventory — the add read as a no-op. An explicit add outranks
the earlier hide, exactly like restore. Only `hidden` is lifted; an approved
row is not walked back down the lifecycle.

Standalone stored a node's live reachability in the inventory row's `status`,
which is the pending/approved/hidden lifecycle — the backend keeps the two
apart as `status` vs `status_live`. Reachability now goes to `status_live`, and
`normalizeLifecycle` repairs rows already written the old way: the node still
reads its status on load, and the next save rewrites the row correctly.

Status checks stay device-scoped and keep covering a device no canvas draws.
That is deliberate — the inventory row is what's monitored, so a rack mount or
an inventory-only entry reports state without being drawn anywhere, and
deleting a node no longer silently stops monitoring the host. `hide`, or
clearing the check method, is what ends the checks. Documented and pinned with
tests rather than changed.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor 0c7fd3c127 fix(canvas): stop a canvas save from reverting a device edited elsewhere
A canvas node carries a full copy of its Device Inventory row, hydrated when
the canvas loads. The save routed all of it back, so a save made for nothing
but a moved node rewrote the row from a snapshot that could be hours old —
silently reverting an edit made meanwhile in the inventory modal, on another
canvas, or by the scanner.

Diffing the payload against the row server-side cannot fix this: it can't tell
"I edited this" from "the row moved on since I loaded it". Only the client
holds the baseline.

- canvasStore keeps `factsBaseline` — the device facts as received — set on
  load, refreshed on save, rebased per field by `applyDeviceFacts`.
- `serializeNode` sends `changed_facts`: what this canvas actually edited.
  `link_facts(changed_fields=)` writes nothing outside that list, even where
  the values differ. Absent (older client, YAML import, MCP) keeps the previous
  full-write behaviour.
- `changed_facts()` additionally drops facts already equal to the row, so a
  no-op save writes nothing. Identity matching still uses the full payload.
- Live fields (status, last_seen…) bypass the filter — the status checker owns
  reachability, and a save only ever fills a row never checked.

An inventory edit also lands on the canvases already on screen:
`applyDeviceFacts` pushes the saved row onto every node drawing it, without
marking the canvas unsaved. A fact the canvas has edited but not saved is left
alone — work in progress wins locally and still saves.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor 19758b2ba1 Revert "chore: bump version to 3.3.0"
This reverts commit 779e09ecb8.

The bump does not belong to this PR. 3.3.0 ships several PRs, so the version
files and the release notes are raised once, in a release PR of their own,
covering the whole diff against main.

ha-relevant: no
2026-08-14 18:02:02 +02:00
Pouzor 1f6812ef2a chore: bump version to 3.3.0
The Device Inventory row becomes the source of a device's facts, and the
device columns are dropped from `nodes` in a one-way migration on first start,
so the release notes lead with a Breaking section: what moved, what the
migration does, when it refuses to drop, and that `homelab.db.back-3.3.0` —
written before any migration runs — is the way back.

ha-relevant: no
2026-08-14 18:02:02 +02:00
Pouzor 9db8b2a7a9 test(standalone): cover a canvas stored in the pre-split shape
An install that predates the split holds canvases whose nodes carry the device
facts inline, with no `devices` key at all. Reading one already worked; nothing
pinned that the next save converts it in place rather than writing the old
shape back.

Covers the load-then-save round trip with every fact intact, the second save
not minting a duplicate row, furniture staying out of the conversion, and the
legacy bare-key canvas carried through `ensureSeed`.

ha-relevant: no
2026-08-14 18:02:02 +02:00
Pouzor d40f73b85c fix(mcp): read a canvas node's facts where the API puts them
`_slim_canvas` filtered `n["data"]`, the React Flow shape the frontend store
holds. `GET /api/v1/canvas` has never sent that: a node is reported flat, so
the filter matched nothing and `get_canvas` returned an id and a node type per
node — no label, no address, no services. The three tests covering it fed a
hand-built nested payload, so they passed against a shape the backend does not
produce.

Fold `data` over the node and read both, so either form slims the same way.
`type` leaves `NODE_KEEP` — flat it is the node type, not a device fact, and it
is already reported as `node_type`; `parentId` becomes `parent_id`, the key the
API uses, normalized from either spelling.

The new tests are built on a full `NodeResponse` copied from a real response,
and the backend now pins that wire shape from its side so the two move together.

ha-relevant: no
2026-08-14 18:02:02 +02:00
Pouzor 71e29362aa fix(inventory): stop a partial node update from blanking the other list
`merge_facts_into_device` applied one `replace_lists` flag to properties
and services alike, taking each wholesale from `facts`. But a PATCH only
carries the keys the client sent, so `{"properties": [...]}` replaced
services with `[]` — losing them for every canvas drawing the device and
stopping the scheduler from service-checking it. Symmetric the other way.

Gate the replace per list: a list absent from `facts` falls through to the
merge, which is a no-op against `None`. An explicit `[]` still clears, and
the canvas save path is unchanged since it always sends both keys.

ha-relevant: no
2026-08-14 18:02:02 +02:00
Pouzor 7a5762e1a0 fix(inventory): read a device's curated type on its inventory card
A device documented straight on a canvas carries a `label` and a `type`
and no discovery guess at all — no scan ever saw it. The card read
`suggested_type` alone, so a printer drawn on a canvas fell back to
'generic': unnamed, filed under no type, and drawn with a plain circle.

Two faults compounded:

- The card decided the kind from `suggested_type`, ignoring the curated
  `type`. That also meant retyping a scanned device in the detail modal
  changed nothing here, and approving it still built a node of the type
  the scanner had guessed.
- It carried its own table of eleven icons, unrelated to the ~40
  NodeType values, so anything outside that list drew a circle even with
  the right type in hand.

`deviceType()` now resolves `type` before `suggested_type` for the icon,
the badge, the type filter and both approve paths, and the icons come
from the shared `NODE_TYPE_DEFAULT_ICONS` — one map, the same one the
canvas and the detail modal use. `deviceLabel` prefers the curated
`label` for the same reason, otherwise a canvas-created device shows as
its IP.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor be2c5ce7e2 feat(inventory): group the device sheet by what a reader is after
The services sat in the curation column, beside the properties, while the
addresses that reach them were two columns away. They move under
Identity: what answers on a host belongs with how to reach it, and the
curation column now carries the properties alone.

Every section heading takes an icon — identity, services, monitoring,
activity, notes, properties, make & model — so a column is scannable
without reading each title. `Section` requires it, so a new section
cannot be added without one. The properties editor keeps its own plain
heading: it comes from the shared `PropertyList`, which the rack cable
panel also renders.

The three view columns carry a data-testid, which is what lets the
layout be asserted rather than eyeballed.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor b4b22cc68f feat(inventory): make the device detail modal readable
The detail modal was a narrow column: a 15px icon, eighteen key-value
rows in one grey block, and a form that grew the dialog to `max-w-lg`
while the header and the buttons scrolled away with the content.

It reads as a device sheet now, following NodeModal's shape:

- One wide dialog with three fixed regions — header, scrolling body,
  footer — so the title and the actions stay put.
- The header identifies the row at a glance: a type-coloured icon tile
  (the accent the node wears on the canvas), then a badge per discovery
  source, the type, live reachability with a dot, and how many canvases
  draw the device.
- Three columns instead of one: identity, operations, curation. A
  section stays put when it has nothing to show and says why, rather
  than disappearing and leaving the reader unsure whether the field is
  absent or unsupported.
- Timestamps read relative with the exact value on hover, matching the
  inventory cards. IP / hostname / MAC / IEEE offer a copy button.
- The form uses the same control sizing as the canvas modals, and gains
  inputs for `friendly_name` and `device_subtype` — both were already
  submitted, so they could not be corrected here.

Hardware is a property like any other, so the modal no longer edits it:
nothing draws `cpu_*` / `ram_gb` / `disk_gb` / `show_hardware`, and the
CPU / RAM / Disk keys are property suggestions instead. The columns are
still sent back untouched — Proxmox and the YAML import write them and
the YAML export reads them.

Two assertions in the edit tests covered the removed section: the
curated-fields test drops the hardware line, and the numeric-payload
test stops editing a field that no longer exists while still checking
the values leave as numbers.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor 21465eee65 fix(canvas): import YAML hardware specs as properties
A node draws its `properties`; `cpu_model` / `cpu_count` / `ram_gb` /
`disk_gb` are inventory data that nothing renders — BaseNode reads them
only when `properties` is absent, which the API never allows (it returns
`device.properties or []`, and the model defaults to a list).

The YAML import filled those fields and set `show_hardware`, so an
imported node did show its specs — until the first save, when the
serializer sent `properties: []`, the fallback died and the specs
silently vanished.

It now mints the same four properties the 3.x migration and the Proxmox
import already create (CPU Model, CPU Cores, RAM, Disk), visible, so the
specs survive the save. The structured fields stay written: the YAML
export reads them back.

`show_hardware` is no longer set — it drives nothing.

The two assertions that covered the old behaviour move with it: the
scalar-field test drops `show_hardware`, and "sets show_hardware only
when hardware fields present" becomes three tests over the minted
properties.

ha-relevant: yes
2026-08-14 18:02:02 +02:00
Pouzor 9b77e43ca1 feat(inventory): drop the device columns from nodes
Completes the split: `nodes` now holds only how a device is drawn on one
canvas, and every device fact reaches the API from the inventory row.

- The device columns are removed from `nodes` (SQLite table rebuild, the
  same shape as the existing device_inventory and canvas_state rebuilds).
  The drop is skipped, and logged, while any non-furniture node is still
  unlinked: those columns are the last copy of that node's facts. The
  backfill therefore reads them with raw SQL — by the time it runs, the
  model no longer declares them.
- The status checker iterates devices, not nodes: one check per device
  however many canvases draw it, writing `status_live` / `last_seen` /
  `response_time_ms` on the row. `/ws/status` messages carry `device_id`
  and the node ids they light up. Hidden devices are not probed.
- Readers repointed: scanner (last_scan lands on the row), proxmox (its
  node tier collapses into the inventory tier, keeping only the cluster
  handles), zigbee/zwave (one property refresh serves every canvas), rack
  inventory, liveview, stats, node dedupe.
- `POST /scan/pending` merges into the row that already describes the host
  instead of minting a second one — one device is one row, whichever way it
  was documented.
- Standalone keeps parity: the canvas blob gains `devices`, split on save
  and hydrated on load. A blob written before the split still reads.

The rack inventory had a related bug: a mount that names a node explicitly
printed the mount's device rather than the pinned node's. It now reads the
node's own row.

Tests that built a node with device columns are ported to the link; where a
behaviour genuinely moved (properties refresh once on the row, last_scan is
the device's) the assertion moved with it rather than being dropped.

ha-relevant: yes
2026-08-14 18:02:02 +02:00
Pouzor e53197d2cb feat(inventory): make the inventory row the source of a node's facts
A device drawn on three canvases was three independent copies of the same
facts. Point every node at the Device Inventory row it draws, and let that
row own what the device *is* — the node keeps only how it is drawn.

- nodes.device_id -> device_inventory.id, ON DELETE SET NULL. NULL for
  canvas furniture (group / groupRect / text), which describes nothing
  physical. Deleting a node never deletes the row.
- services/inventory_sync holds the shared rules: matching by ieee > ip >
  mac (per token, so 10.0.0.4 never matches 10.0.0.40), a property union on
  key, a service union on (port, protocol, name), and the backfill that
  links every pre-existing node.
- The backfill is non-destructive by construction: it writes device_id and
  fills the row, and deletes nothing. Nodes are visited oldest-edit-first,
  so where two canvases disagree on a scalar the most recently edited wins,
  while properties and services stay unioned — nothing any canvas recorded
  is lost. A second boot finds nothing to do.
- The wire shape does not change: GET /canvas hydrates the device fields
  from the row, and a save routes them back to it. Editing a node's IP on
  one canvas now shows on every other canvas holding that device.
- approve / bulk-approve set device_id instead of owning a copy, and a new
  canvas node joins (or mints) its row. Rows minted this way are tagged
  with a new `canvas` discovery source and get their own inventory filter.
- DetailPanel offers "Open in inventory"; standalone, which has no
  inventory, is not offered it.

test_racks' "reports what the canvas node knows" seeded a second inventory
row for a host that already had one — a state a node create can no longer
produce. Its seeding order is swapped so the node links to the row; every
assertion is unchanged.

ha-relevant: yes
2026-08-14 18:02:02 +02:00
Pouzor 4393ac92c6 feat(inventory): show and edit a device from the inventory
The Device Inventory was a read-only discovery log: rows arrived from a
scan or an import and went stale. Everything a user curates — properties,
services, notes, hardware, check method — could only be edited on a canvas
node, and never came back.

Give the inventory row the fields a node carries and a way to write them:

- device_inventory gains label, type, notes, cpu_count/cpu_model/ram_gb/
  disk_gb, show_hardware, check_method/check_target, last_seen, last_scan,
  response_time_ms and updated_at. Live reachability goes in a new
  status_live: `status` already means the pending/approved/hidden lifecycle
  and the two must not be conflated.
- PATCH /api/v1/scan/pending/{id} applies only what the client sends, so
  editing one field never clears the rest. Lifecycle and discovery
  bookkeeping stay owned by the approve/hide routes and the importers.
- InventoryDeviceCreate carries the same fields, so a hand-made entry needs
  no create-then-PATCH round trip.
- InventoryDeviceModal becomes show *and* edit, reusing the canvas editors
  (PropertyList, ServiceModal) rather than growing a second implementation.
- InventoryEntry moves to types/index.ts, its home; the modal re-exports it
  so existing call sites are untouched. NODE_TYPE_GROUPS moves to
  utils/nodeTypeGroups so both type pickers share one vocabulary.

ha-relevant: maybe
2026-08-14 18:02:02 +02:00
Pouzor 59079d90ca fix(canvas): seed a group child near its group and refuse nested groups
Follow-ups to the grouping fix. `handleAddNode` still gated its
"position near the parent" branch on `container_mode`, so a node added
straight into a group got a viewport-centred coordinate that `addNode`
then made group-relative — it landed far outside the box.

`isValidParentNode` also accepted a group or a zone as the child of a
group, which the drag-onto-a-group path refuses; both now agree.

ha-relevant: yes
2026-08-13 19:16:47 +02:00
Pouzor 26e18dafdd fix(canvas): keep a node inside its group when editing it
Editing any field of a grouped node dropped it out of the group on the
next save. NodeModal validated the parent against the type rules
(proxmox/vm/lxc/docker_host) or `container_mode`, and a `group` node is
neither — so it cleared `parent_id` on submit, `updateNode` detached the
node and the save persisted `parent_id: null`.

A shared `isValidParentNode` now treats a group as a valid parent for
any child type; the type rules only govern container nesting. Two
siblings of the same bug go with it: `updateNode` and `addNode` only
nested under a `container_mode` parent, so assigning or adding a node to
a group left it visually unparented until the next reload.

ha-relevant: yes
2026-08-13 19:16:47 +02:00
Pouzor c796cb39bd fix(services): split a comma-listed host override and type the wire host
The frontend takes the first host of a comma-separated override
(`splitFirstHost`), the backend took the whole string — so a two-host
override checked a hostname the UI never links to. `_parse_override`
now splits the same way.

The WebSocket service-status entry also declares `host` (nullable, as
the backend sends it), so the overlay key stops relying on an
undeclared field.

ha-relevant: yes
2026-08-13 19:16:47 +02:00
Pouzor a93b0408b8 fix(services): key and check a host-overridden service by its own host
Follow-up to the per-service host override: the status checker still
probed the node host, and the live overlay keyed services on
node/port/protocol only — so several vhosts sharing port 443 all read
one status. `check_service` now resolves the override (scheme, port,
IPv6 literal), echoes it back in the payload, and `serviceStatusKey`
takes the host as part of the key.

Also guard `new URL()` in `getServiceUrl`: a hand-typed "https://"
threw mid-render instead of resolving to no URL.

ha-relevant: yes
2026-08-13 19:16:47 +02:00
Pouzor e40ac25b20 feat(services): allow a per-service host override
A node can serve several domains, so a service now carries an optional
`host` that overrides the node ip/hostname when building its URL. Empty
falls back to the node host, and the override accepts the same shapes
(`host`, `host:port`, `https://host/…`). The canvas link and the detail
panel badge both resolve through `getServiceUrl`, so they follow it.

Closes #332

ha-relevant: yes
2026-08-13 19:16:47 +02:00
Pouzor e93aef3871 fix(canvas): show alignment guides for nodes inside groups and containers
Alignment guides and snapping were skipped for any node with a parentId, so
dragging a node inside a group or a container_mode node (Proxmox, docker host)
gave no visual help at all.

Boxes are now expressed in absolute canvas coordinates by walking the parentId
chain, so a child aligns against its siblings, its parent group, and top-level
nodes outside the group. A node whose ancestor is also being dragged follows
that ancestor: it is excluded from the snap (its parent-relative position would
otherwise be shifted twice) and from the candidate set.

Fixes #328

ha-relevant: yes
2026-08-13 00:58:58 +02:00
Pouzor 79b682148e feat(nodes): allow 0 connection points on top and bottom
Top and bottom were floored at 1 while left and right could go to 0.
MIN_HANDLES is now 0 for every side; sideDefault keeps its role as the
value used when a node carries no explicit count (1 for top/bottom,
0 for left/right), so existing canvases are unchanged.

YAML round-trip: export writes a count that differs from the side
default (not only one above it), and import tests for a number rather
than truthiness, so an explicit 0 survives.

ha-relevant: yes
2026-08-13 00:11:25 +02:00
Pouzor 5040b70073 feat(nodes): add KVM switch as a device type
Adds `kvm` to NodeType, with a label, a MonitorCog icon, a per-theme
accent and a registered node component, plus the Hardware group of the
node and custom-style pickers and the MCP NODE_TYPES list.

Scanner autodetect: port-agnostic signatures for PiKVM, TinyPilot,
JetKVM and NanoKVM, matched on the HTTP probe's title/headers, and `kvm`
placed ahead of `server` in the suggest_node_type priority list.

suggest_node_type called match_port, which drops the HTTP-probe signals,
so no `port: null` signature could influence a suggested type — Unraid,
OpenMediaVault and Cockpit included. It now calls match_service with the
probe signals, which is what makes the KVM signatures reachable.

Closes #314

ha-relevant: yes
2026-08-12 23:15:43 +02:00
1343391b0d fix: apply the status check interval to the running scheduler
POST /api/v1/settings persisted the new status check interval and echoed it
back, but never rescheduled the running job: reschedule_status_checks() is
defined in app/core/scheduler.py and has no caller anywhere. The UI and
scan_config.json show the new value while the scheduler keeps firing at the
old one until the backend restarts.

On our install this went unnoticed for three weeks: 3600s was configured but
30s stayed in effect, so ~120 nodes were pinged 120x more often than intended
with no visible symptom.

Also add ge=10 to interval_seconds, mirroring the floor already enforced by
reschedule_status_checks(). Without it a value below 10 is written to
scan_config.json first and only then raises, which surfaces as a 500 and
leaves the invalid value to be reloaded on the next boot.

Both new tests fail without the fix: the first with AttributeError (the route
does not import the function), the second with 200 instead of 422.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 11:02:12 +02:00
120 changed files with 7220 additions and 1228 deletions
+28
View File
@@ -5,6 +5,34 @@ 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.3.1] - 2026-08-17
### Fixes
- Approving a device no longer fails after an upgrade from 3.2.0: a node the 3.3.0 inventory backfill could not link kept the old device columns on `nodes`, and their NOT NULL constraint rejected every new node. (#352)
## [3.3.0] - 2026-08-15
### Features
- The Device Inventory is now the source of truth for device data: a node reads and edits its facts from its inventory row, and the device sheet is grouped by what a reader is after. (#339)
- Per-service host override, so a service can point at a host other than the node's own. (#338)
- A zone now parents the nodes dropped inside it. (#343)
- Node properties accept an empty value. (#345)
- Nodes can be given 0 connection points on top and bottom. (#336)
- KVM switch added as a device type. (#335)
- The sidebar version row links to the public changelog.
### Fixes
- Alignment guides show for nodes inside groups and containers. (#337)
- The status check interval is applied to the running scheduler. Thanks @JaG-v2. (#319)
- Bumped undici and ip-address to patch high severity advisories. (#342)
### Docs
- The pre-built GHCR images are documented in the install guide.
## [3.2.0] - 2026-08-10
### Features
+25
View File
@@ -40,8 +40,33 @@ curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/install.sh |
cd homelable && docker compose pull && docker compose up -d
```
## Pre-built Docker images
The quick starts above never build anything — `install.sh` writes `docker-compose.prebuilt.yml` (or `docker-compose.standalone.yml` with `--standalone`) as your `docker-compose.yml`, and both pull ready-made images. They are published to the GitHub Container Registry on every push to `main` and every `v*` tag, for `linux/amd64` and `linux/arm64`:
| Image | Contents |
|---|---|
| [`ghcr.io/pouzor/homelable-backend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-backend) | FastAPI API, scanner, status checker |
| [`ghcr.io/pouzor/homelable-frontend`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend) | React SPA behind nginx, proxying `/api` to the backend |
| [`ghcr.io/pouzor/homelable-frontend-standalone`](https://github.com/Pouzor/homelable/pkgs/container/homelable-frontend-standalone) | Same SPA built with `VITE_STANDALONE=true` — no backend, canvases in `localStorage` |
| [`ghcr.io/pouzor/homelable-mcp`](https://github.com/Pouzor/homelable/pkgs/container/homelable-mcp) | MCP server exposing the canvas to AI clients |
Tags: `latest` (tip of `main`), plus `X.Y.Z` and `X.Y` for releases.
To wire it up by hand instead of using `install.sh`:
```bash
curl -fsSLO https://raw.githubusercontent.com/Pouzor/homelable/main/docker-compose.prebuilt.yml
curl -fsSL https://raw.githubusercontent.com/Pouzor/homelable/main/.env.example -o .env
docker compose -f docker-compose.prebuilt.yml up -d
```
Update the same way: `docker compose -f docker-compose.prebuilt.yml pull && … up -d`.
## Build from source
`docker-compose.yml` at the repo root builds the images locally instead of pulling them — use it for development, or to run a patched tree.
```bash
git clone https://github.com/Pouzor/homelable.git
cd homelable
+1 -1
View File
@@ -65,7 +65,7 @@ Every feature, with how to turn it on and use it, is described in **[FEATURES.md
## Installation
Docker, Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
Docker (from the **[pre-built GHCR images](./INSTALLATION.md#pre-built-docker-images)**), Proxmox LXC, build from source, configuration, and development setup are all covered in **[INSTALLATION.md](./INSTALLATION.md)**.
---
+1 -1
View File
@@ -1 +1 @@
3.2.0
3.3.1
+40 -5
View File
@@ -12,6 +12,13 @@ from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasSaveRequest, CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.services.inventory_sync import (
facts_from_payload,
hydrated_node,
link_facts,
load_devices_for,
node_columns,
)
router = APIRouter()
@@ -28,12 +35,15 @@ async def load_canvas(
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
# The device facts live on the inventory row now; hydrate them back into the
# node so the wire shape the canvas consumes is unchanged.
devices = await load_devices_for(db, nodes)
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=state.custom_style if state else None,
@@ -74,16 +84,41 @@ async def save_canvas(
await db.flush()
# Upsert nodes
# Upsert nodes, then route their device facts to the inventory row that owns
# them — an edit made on this canvas is an edit to the device itself, and
# every other canvas showing it follows.
saved: list[tuple[Node, dict[str, Any], list[str] | None]] = []
for node_data in body.nodes:
db_node = await db.get(Node, node_data.id)
payload = node_data.model_dump()
payload["design_id"] = design_id
facts = facts_from_payload(payload, label=node_data.label, node_type=node_data.type)
columns = node_columns(payload)
if db_node:
for field, value in payload.items():
for field, value in columns.items():
setattr(db_node, field, value)
else:
db.add(Node(**payload))
db_node = Node(**columns)
db.add(db_node)
saved.append((db_node, facts, node_data.changed_facts))
await db.flush()
for node, facts, changed in saved:
# The payload carries a full copy of each device, hydrated when this
# canvas loaded. Routing all of it back would rewrite the row from a
# stale snapshot — a save made for nothing but a moved node could revert
# an edit meanwhile in the inventory modal. `changed_facts` (when the
# client tracked it) plus the row diff narrow the write to this canvas'
# actual edit.
await link_facts(
db,
node,
facts,
overwrite_scalars=True,
replace_lists=True,
only_changed=True,
changed_fields=changed,
)
# Upsert edges
for edge_data in body.edges:
+5 -2
View File
@@ -13,6 +13,7 @@ from app.db.models import CanvasState, Design, Edge, Node
from app.schemas.canvas import CanvasStateResponse
from app.schemas.edges import EdgeResponse
from app.schemas.nodes import NodeResponse
from app.services.inventory_sync import hydrated_node, load_devices_for
router = APIRouter()
@@ -59,13 +60,15 @@ async def liveview_canvas(
if design_id is None:
return CanvasStateResponse(nodes=[], edges=[], viewport={"x": 0, "y": 0, "zoom": 1}, custom_style=None)
nodes = (await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all()
nodes = list((await db.execute(select(Node).where(Node.design_id == design_id))).scalars().all())
edges = (await db.execute(select(Edge).where(Edge.design_id == design_id))).scalars().all()
state = await db.get(CanvasState, design_id)
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
custom_style: dict[str, Any] | None = state.custom_style if state else None
# Device facts come off the inventory row, same as the editable canvas.
devices = await load_devices_for(db, nodes)
return CanvasStateResponse(
nodes=[NodeResponse.model_validate(n) for n in nodes],
nodes=[NodeResponse.model_validate(hydrated_node(n, devices.get(n.device_id or ""))) for n in nodes],
edges=[EdgeResponse.model_validate(e) for e in edges],
viewport=viewport,
custom_style=custom_style,
+47 -11
View File
@@ -1,11 +1,21 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.db.models import Design, Node
from app.db.models import Design, InventoryDevice, Node
from app.schemas.nodes import NodeCreate, NodeResponse, NodeUpdate
from app.services.inventory_sync import (
facts_from_payload,
facts_from_update,
hydrated_node,
link_facts,
load_devices_for,
node_columns,
)
from app.services.node_dedupe import find_duplicate_node
router = APIRouter()
@@ -59,15 +69,17 @@ async def list_nodes(
label: str | None = Query(None, description="Case-insensitive substring filter on node label"),
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> list[Node]:
) -> list[Any]:
query = select(Node)
if label:
query = query.where(Node.label.ilike(f"%{label}%"))
return list((await db.execute(query)).scalars().all())
nodes = list((await db.execute(query)).scalars().all())
devices = await load_devices_for(db, nodes)
return [hydrated_node(n, devices.get(n.device_id or "")) for n in nodes]
@router.post("", response_model=NodeResponse, status_code=status.HTTP_201_CREATED)
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
data = body.model_dump()
# `force` bypasses the duplicate guard below; it is not a Node column.
force = data.pop("force", False)
@@ -103,33 +115,57 @@ async def create_node(body: NodeCreate, db: AsyncSession = Depends(get_db), _: s
if data["pos_y"] is None:
data["pos_y"] = 0.0
node = Node(**data)
node = Node(**node_columns(data))
db.add(node)
await db.flush()
# A new device node gets (or joins) its Device Inventory row — the canvas is
# one more way to document hardware, not a parallel store.
await link_facts(
db, node, facts_from_payload(data, label=data["label"], node_type=data["type"])
)
await db.commit()
await db.refresh(node)
return node
return await _hydrate(db, node)
async def _hydrate(db: AsyncSession, node: Node) -> dict[str, Any]:
"""Node as the API reports it, with the device facts read off its row."""
device = await db.get(InventoryDevice, node.device_id) if node.device_id else None
return hydrated_node(node, device)
@router.get("/{node_id}", response_model=NodeResponse)
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Node:
async def get_node(node_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)) -> Any:
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
return node
return await _hydrate(db, node)
@router.patch("/{node_id}", response_model=NodeResponse)
async def update_node(
node_id: str, body: NodeUpdate, db: AsyncSession = Depends(get_db), _: str = Depends(get_current_user)
) -> Node:
) -> Any:
node = await db.get(Node, node_id)
if not node:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Node not found")
for field, value in body.model_dump(exclude_unset=True).items():
sent = body.model_dump(exclude_unset=True)
for field, value in node_columns(sent).items():
setattr(node, field, value)
# The user edited the device, not just its drawing: push the facts down.
# Only what was sent counts — an omitted field must not clear the row, and
# lists are replaced only when the client actually sent them.
facts = facts_from_update(sent)
await link_facts(
db,
node,
facts,
overwrite_scalars=True,
replace_lists="properties" in sent or "services" in sent,
)
await db.commit()
await db.refresh(node)
return node
return await _hydrate(db, node)
@router.delete("/{node_id}", status_code=status.HTTP_204_NO_CONTENT)
+38 -40
View File
@@ -35,7 +35,7 @@ from app.schemas.proxmox import (
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.node_dedupe import dedupe_nodes_by_device
from app.services.proxmox_service import (
build_proxmox_cluster_links,
build_proxmox_properties,
@@ -254,7 +254,7 @@ async def _persist_pending_import(
Update-in-place only. Nothing is ever deleted; hidden rows stay hidden.
"""
await dedupe_nodes_by_ieee(db)
await dedupe_nodes_by_device(db)
cluster_pairs = build_proxmox_cluster_links(nodes_raw)
cluster_members = {ieee for pair in cluster_pairs for ieee in pair}
@@ -270,51 +270,44 @@ async def _persist_pending_import(
mac = normalize_mac(n.get("mac"))
props = build_proxmox_properties(n)
# 1) Already on a canvas? Match by ieee OR ip OR mac (the cross-source
# dedup key — a stopped VM has no IP but its configured NIC MAC still
# matches an ARP-scanned node). Refresh in place: merge properties, adopt
# the pve identity onto a scanned node, backfill blank specs/hostname/mac.
# Do NOT stomp user-set type/status.
node_filter = [Node.ieee_address == ieee]
if ip:
node_filter.append(Node.ip == ip)
if mac:
node_filter.append(Node.mac == mac)
existing_nodes = (
await db.execute(select(Node).where(or_(*node_filter)).order_by(Node.id))
).scalars().all()
if existing_nodes:
for en in existing_nodes:
en.properties = merge_proxmox_properties(en.properties, props)
if not en.ieee_address:
en.ieee_address = ieee
if ip and not en.ip:
en.ip = ip
if mac and not en.mac:
en.mac = mac
en.hostname = en.hostname or n.get("hostname")
en.cpu_count = en.cpu_count or n.get("cpu_count")
en.ram_gb = en.ram_gb or n.get("ram_gb")
en.disk_gb = en.disk_gb or n.get("disk_gb")
# A cluster host needs one left + one right handle for the
# cluster edge endpoints (both default to 0).
if ieee in cluster_members:
en.left_handles = max(en.left_handles or 0, 1)
en.right_handles = max(en.right_handles or 0, 1)
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
pending_updated += 1
continue
# 2) Not on canvas — upsert the pending inventory row.
# Match by ieee OR ip OR mac (the cross-source dedup key — a stopped VM
# has no IP but its configured NIC MAC still matches an ARP-scanned
# device). The inventory row owns the facts, so the import merges into
# it whether or not the device is drawn anywhere; a node only needs its
# cluster handles adjusted. Do NOT stomp user-set type/status.
pending = await _find_pending(db, ieee, ip, mac)
drawn = bool(
pending
and (
await db.execute(select(Node.id).where(Node.device_id == pending.id).limit(1))
).scalar_one_or_none()
)
if pending is None:
db.add(_new_pending(ieee, ip, mac, n, props, status="pending"))
pending_created += 1
else:
_refresh_pending(pending, ieee, ip, mac, n, props)
if drawn:
# Already on a canvas: refresh the facts but leave the lifecycle
# alone (_refresh_pending would revive it as pending).
await _ensure_inventory_row(db, ieee, ip, mac, n, props, approved=True)
else:
_refresh_pending(pending, ieee, ip, mac, n, props)
pending.cpu_count = pending.cpu_count or n.get("cpu_count")
pending.ram_gb = pending.ram_gb or n.get("ram_gb")
pending.disk_gb = pending.disk_gb or n.get("disk_gb")
pending_updated += 1
# A cluster host needs one left + one right handle for the cluster
# edge endpoints (both default to 0) — the one piece of this that is
# genuinely about how the node is drawn.
if ieee in cluster_members:
for en in (
await db.execute(select(Node).where(Node.device_id == pending.id))
).scalars().all():
en.left_handles = max(en.left_handles or 0, 1)
en.right_handles = max(en.right_handles or 0, 1)
links_recorded = await _replace_links(db, edges_raw, cluster_pairs)
await db.commit()
@@ -357,6 +350,11 @@ def _new_pending(
vendor=n.get("vendor"),
model=n.get("model"),
properties=props,
# Specs the guest reports. They used to land only on the canvas node;
# the inventory row owns them now.
cpu_count=n.get("cpu_count"),
ram_gb=n.get("ram_gb"),
disk_gb=n.get("disk_gb"),
status=status,
discovery_source=_PROXMOX_GUEST_SOURCE,
discovery_sources=[_PROXMOX_GUEST_SOURCE],
+32 -27
View File
@@ -286,37 +286,38 @@ async def rack_inventory(
# to find anything in the first place.
pinned = {device_id: node_id for device_id, node_id in mounts if device_id and node_id}
# Correlate against canvas nodes for live status: IEEE first (stable), then IP.
# Correlate against canvas nodes: a node names the inventory row it draws,
# so the IEEE/IP guessing this used to do is gone — the link is explicit.
nodes = (await db.execute(select(Node))).scalars().all()
by_id = {n.id: n for n in nodes}
by_ieee = {n.ieee_address: n for n in nodes if n.ieee_address}
by_ip: dict[str, Node] = {}
by_device: dict[str, Node] = {}
for node in nodes:
for token in _ip_tokens(node.ip):
by_ip.setdefault(token, node)
if node.device_id:
by_device.setdefault(node.device_id, node)
# Which canvas the matched node lives on — the rack prints it so the user
# knows where the logical twin of a mount is drawn.
design_names = {
d.id: d.name for d in (await db.execute(select(Design))).scalars().all()
}
# A pinned node may draw a *different* device than the mount names, so its
# view is read off its own row.
devices_by_id = {d.id: d for d in devices}
items = []
for device in devices:
if device.suggested_type in _UNRACKABLE_TYPES:
continue
# A node the user linked by hand wins; a deleted one falls back to the
# guess rather than reporting a node that no longer exists.
linked: Node | None = by_id.get(pinned.get(device.id) or "")
if linked is None and device.ieee_address:
linked = by_ieee.get(device.ieee_address)
if linked is None:
linked = next((by_ip[t] for t in _ip_tokens(device.ip) if t in by_ip), None)
# The node is the richer record once a device has been approved onto a
# canvas; the inventory row is what discovery last saw. Report both and
# let the panel prefer the node.
services = _services(device.services) or (
_services(linked.services) if linked else []
)
# node that draws this device, if any.
linked: Node | None = by_id.get(pinned.get(device.id) or "") or by_device.get(device.id)
# The logical view is whatever the linked node actually draws. Usually
# that is this same row — the node_* fields then simply mirror it.
node_device = None
if linked is not None:
node_device = devices_by_id.get(linked.device_id or "")
if node_device is None and linked.device_id:
node_device = await db.get(InventoryDevice, linked.device_id)
items.append(
RackInventoryItem(
id=device.id,
@@ -328,21 +329,25 @@ async def rack_inventory(
mac=device.mac or device.ieee_address,
hostname=device.hostname,
os=device.os,
services=services,
services=_services(device.services),
node_id=linked.id if linked else None,
node_status=linked.status if linked else None,
node_label=linked.label if linked else None,
node_type=linked.type if linked else None,
node_ip=linked.ip if linked else None,
node_mac=(linked.mac or linked.ieee_address) if linked else None,
node_hostname=linked.hostname if linked else None,
node_os=linked.os if linked else None,
node_check_method=linked.check_method if linked else None,
node_status=node_device.status_live if node_device else None,
node_label=(
(node_device.label if node_device else None) or linked.label
) if linked else None,
node_type=(
(node_device.type if node_device else None) or linked.type
) if linked else None,
node_ip=node_device.ip if node_device else None,
node_mac=(node_device.mac or node_device.ieee_address) if node_device else None,
node_hostname=node_device.hostname if node_device else None,
node_os=node_device.os if node_device else None,
node_check_method=node_device.check_method if node_device else None,
node_design_id=linked.design_id if linked else None,
node_design_name=(
design_names.get(linked.design_id) if linked and linked.design_id else None
),
node_last_seen=linked.last_seen if linked else None,
node_last_seen=node_device.last_seen if node_device else None,
racked=device.id in mounted,
)
)
+183 -121
View File
@@ -22,9 +22,16 @@ from app.db.models import (
ScanRun,
)
from app.schemas.nodes import NodeCreate
from app.schemas.scan import InventoryDeviceCreate, InventoryDeviceResponse, ScanRunResponse
from app.schemas.scan import (
InventoryDeviceCreate,
InventoryDeviceResponse,
InventoryDeviceUpdate,
ScanRunResponse,
)
from app.services.discovery_sources import add_source
from app.services.inventory_sync import find_device_for, merge_properties, merge_services
from app.services.mac_utils import normalize_mac
from app.services.node_dedupe import dedupe_nodes_by_ieee, find_duplicate_node
from app.services.node_dedupe import dedupe_nodes_by_device, 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,
@@ -244,65 +251,38 @@ def _agg(values: list[datetime], *, newest: bool) -> datetime | None:
async def _canvas_correlation(
db: AsyncSession, devices: list[InventoryDevice]
) -> dict[str, dict[str, Any]]:
"""Correlate each device to existing canvas nodes by ``ieee_address``, ``mac``
or ``ip``.
"""Correlate each device to the canvas nodes drawing it.
Returns, per device id: the number of distinct canvases (designs) it appears
on, plus aggregated timestamps from every matching node — created_at (oldest),
last_scan / updated_at / last_seen (newest). One node query, grouped in Python
(node counts are small for a homelab), so no N+1 per device.
on, plus aggregated node timestamps — created_at (oldest) and updated_at
(newest). A node names the inventory row it draws, so this is a group-by on
``device_id`` rather than the ieee/mac/ip guesswork it used to be.
IP matching is per-address: a node's ``ip`` may hold several comma-separated
addresses (e.g. an IPv6 added before the IPv4), so we index each token, not
the raw string. MAC is a stable identifier immune to such IP edits, so it is
matched too — cumulatively with ieee/ip (issue #258).
``last_scan`` and ``last_seen`` come off the device row itself now: they are
observations of the device, not of any one drawing of it.
"""
if not devices:
return {}
rows = (
await db.execute(
select(
Node.ip,
Node.mac,
Node.ieee_address,
Node.design_id,
Node.created_at,
Node.last_scan,
Node.updated_at,
Node.last_seen,
).where(Node.design_id.isnot(None))
select(Node.device_id, Node.design_id, Node.created_at, Node.updated_at)
.where(Node.design_id.isnot(None), Node.device_id.isnot(None))
)
).all()
# Index matching nodes by ip token, mac and ieee so a device can look up any.
by_ip: dict[str, list[Any]] = {}
by_mac: dict[str, list[Any]] = {}
by_ieee: dict[str, list[Any]] = {}
by_device: dict[str, list[Any]] = {}
for row in rows:
for tok in _ip_tokens(row.ip):
by_ip.setdefault(tok, []).append(row)
if row.mac:
by_mac.setdefault(row.mac, []).append(row)
if row.ieee_address:
by_ieee.setdefault(row.ieee_address, []).append(row)
by_device.setdefault(row.device_id, []).append(row)
info: dict[str, dict[str, Any]] = {}
for d in devices:
matched = []
if d.ieee_address:
matched += by_ieee.get(d.ieee_address, [])
if d.mac:
matched += by_mac.get(d.mac, [])
for tok in _ip_tokens(d.ip):
matched += by_ip.get(tok, [])
# De-duplicate nodes matched by more than one identifier.
matched = list({id(m): m for m in matched}.values())
matched = by_device.get(d.id, [])
designs = {m.design_id for m in matched}
info[d.id] = {
"canvas_count": len(designs),
"node_created_at": _agg([m.created_at for m in matched], newest=False),
"node_last_scan": _agg([m.last_scan for m in matched], newest=True),
"node_last_scan": d.last_scan,
"node_last_modified": _agg([m.updated_at for m in matched], newest=True),
"node_last_seen": _agg([m.last_seen for m in matched], newest=True),
"node_last_seen": d.last_seen,
}
return info
@@ -341,13 +321,41 @@ async def create_pending(
Lands as ``status="pending"`` like a discovery would, so the existing approve
/ hide / restore flows apply unchanged.
"""
# One device is one row. If this host is already known — by ieee, ip or mac —
# the user is documenting the device they already have, so fill in what the
# row is missing rather than splitting it in two.
mac = normalize_mac(body.mac)
existing = await find_device_for(db, ip=body.ip, mac=mac, ieee=None)
if existing is not None:
for field in (
"hostname", "ip", "os", "suggested_type", "model", "vendor", "label", "type",
"notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb", "check_method",
"check_target", "friendly_name", "device_subtype",
):
value = getattr(body, field, None)
if value not in (None, "") and getattr(existing, field, None) in (None, ""):
setattr(existing, field, value)
existing.mac = existing.mac or mac
existing.properties = merge_properties(existing.properties, body.properties)
existing.services = merge_services(existing.services, body.services)
existing.discovery_sources = add_source(existing.discovery_sources, body.discovery_source)
# A hidden row is still that device, so it is the one to fill in — but
# adding a device by hand is asking for it, and returning it still hidden
# would leave the user with a 201 and nothing in the inventory. The
# explicit add outranks the earlier hide, exactly like restore.
if existing.status == "hidden":
existing.status = "pending"
await db.commit()
await db.refresh(existing)
return (await _with_canvas_counts(db, [existing]))[0]
device = InventoryDevice(
hostname=body.hostname,
ip=body.ip,
# Canonical form, like every other write path: dedup compares MACs by
# equality, so a hand-typed "AA-BB-CC-11-22-33" would never match the
# scanned "aa:bb:cc:11:22:33" and approve would build a duplicate node.
mac=normalize_mac(body.mac),
mac=mac,
suggested_type=body.suggested_type,
model=body.model,
vendor=body.vendor,
@@ -355,6 +363,20 @@ async def create_pending(
status="pending",
discovery_source=body.discovery_source,
discovery_sources=[body.discovery_source],
os=body.os,
services=body.services,
friendly_name=body.friendly_name,
device_subtype=body.device_subtype,
label=body.label,
type=body.type,
notes=body.notes,
cpu_count=body.cpu_count,
cpu_model=body.cpu_model,
ram_gb=body.ram_gb,
disk_gb=body.disk_gb,
show_hardware=body.show_hardware,
check_method=body.check_method,
check_target=body.check_target,
)
db.add(device)
await db.commit()
@@ -362,6 +384,37 @@ async def create_pending(
return (await _with_canvas_counts(db, [device]))[0]
@router.patch("/pending/{device_id}", response_model=InventoryDeviceResponse)
async def update_pending(
device_id: str,
body: InventoryDeviceUpdate,
db: AsyncSession = Depends(get_db),
_: str = Depends(get_current_user),
) -> InventoryDevice:
"""Edit an inventory row — the device facts, not its lifecycle.
The inventory row owns what a device *is*; a canvas node only owns how it is
drawn. This is the write path behind the device detail modal, and applies
only the fields the client actually sent.
"""
device = (
await db.execute(select(InventoryDevice).where(InventoryDevice.id == device_id))
).scalar_one_or_none()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
data = body.model_dump(exclude_unset=True)
if "mac" in data:
# Same reason as create_pending: dedup matches MACs by equality.
data["mac"] = normalize_mac(data["mac"])
for field, value in data.items():
setattr(device, field, value)
await db.commit()
await db.refresh(device)
return (await _with_canvas_counts(db, [device]))[0]
@router.delete("/pending", response_model=dict)
async def clear_pending(
db: AsyncSession = Depends(get_db),
@@ -417,7 +470,7 @@ async def bulk_approve_devices(
_: str = Depends(get_current_user),
) -> dict[str, Any]:
# Repair any legacy same-canvas duplicate nodes before placing more.
await dedupe_nodes_by_ieee(db)
await dedupe_nodes_by_device(db)
# Target the design the user is on; fall back to the first design.
default_design_id = payload.design_id
@@ -438,25 +491,18 @@ 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, mac or ieee_address) instead of creating duplicate nodes. We
# map to the existing node id so the skip report can point the user at it. A
# value may be a Node still pending flush (in-batch duplicate) — resolved to
# its id after the flush below. IPs are indexed per comma-separated token so
# a node whose ip is "fe80::1, 192.168.1.5" still matches a device scanned as
# 192.168.1.5 (issue #258).
# What already sits on the target canvas. A node names the inventory row it
# draws, so "already placed" is now a device-id lookup rather than a guess
# across ip/mac/ieee. The value may be a Node still pending flush (in-batch
# duplicate) — resolved to its id after the flush below.
existing = (
await db.execute(
select(Node.id, Node.ip, Node.mac, Node.ieee_address).where(
Node.design_id == default_design_id
select(Node.id, Node.device_id).where(
Node.design_id == default_design_id, Node.device_id.isnot(None)
)
)
).all()
placed_ips: dict[str, Any] = {
tok: nid for nid, ip, _, _ in existing for tok in _ip_tokens(ip)
}
placed_mac: dict[str, Any] = {mac: nid for nid, _, mac, _ in existing if mac}
placed_ieee: dict[str, Any] = {ieee: nid for nid, _, _, ieee in existing if ieee}
placed: dict[str, Any] = {device_id: nid for nid, device_id in existing}
created_nodes: list[Node] = []
approved_devices: list[InventoryDevice] = []
@@ -470,66 +516,58 @@ async def bulk_approve_devices(
"match": "rack", "value": "rack device", "_ref": None,
})
continue
# 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:
# Report which identifier names the device, so the caller can explain
# the skip and link to the node already drawing it.
if device.id in placed:
match, value = (
("ip", device.ip) if device.ip
else ("ieee", device.ieee_address) if device.ieee_address
else ("mac", device.mac)
)
skipped_devices.append({
"device_id": device.id,
"label": device.hostname or device.friendly_name or device.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],
"label": device.hostname or device.friendly_name or value or "device",
"match": match, "value": value, "_ref": placed[device.id],
})
continue
device.status = "approved"
node_type = device.suggested_type or "generic"
is_wireless = _is_wireless(node_type)
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
# Enrich the row, not the node: a mesh device's radio properties and the
# default check method describe the device itself.
device.type = device.type or node_type
device.label = device.label or device.hostname or device.friendly_name or device.ip or "device"
device.properties = (
_wireless_properties(node_type, device.ieee_address, device.vendor, device.model, device.lqi)
if is_wireless
else merge_mac_property(list(device.properties or []), device.mac)
)
if is_wireless:
# A mesh device answers no ICMP; being in the mesh is the liveness.
device.check_method = "none"
device.status_live = "online"
elif not device.check_method:
# Default to ping so the status checker actually polls it. Without
# this the scheduler skips it (check_method NULL -> no check).
device.check_method = "ping" if device.ip else None
node = Node(
label=device.hostname or device.friendly_name or device.ip or "device",
type=node_type,
ip=device.ip,
mac=device.mac,
hostname=device.hostname,
status="online" if is_wireless else "unknown",
services=device.services or [],
ieee_address=device.ieee_address,
properties=_wireless_properties(
node_type, device.ieee_address, device.vendor, device.model, device.lqi
) if is_wireless else merge_mac_property(list(device.properties or []), device.mac),
# Default to ping so the status checker actually polls the new node.
# Without this the scheduler skips it (check_method NULL → no check).
check_method="none" if is_wireless else ("ping" if device.ip else None),
# Cluster hosts get side handles for their host↔host cluster edge.
label=device.label,
type=device.type,
# Cluster hosts get side handles for their host<->host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=default_design_id,
# The node draws this inventory row; the row owns the facts.
device_id=device.id,
)
db.add(node)
created_nodes.append(node)
approved_devices.append(device)
# Track within this batch so a duplicate selection (same ip/mac/ieee) is
# not placed twice on the same canvas. Store the Node so a later in-batch
# skip can resolve to its id after flush.
for tok in _ip_tokens(device.ip):
placed_ips[tok] = node
if device.mac:
placed_mac[device.mac] = node
if device.ieee_address:
placed_ieee[device.ieee_address] = node
# Track within this batch so a duplicate selection is not placed twice
# on the same canvas. Stores the Node so a later in-batch skip can
# resolve to its id after flush.
placed[device.id] = node
await db.flush() # populates node.id from Python-side default before reading
# node_ids and approved_device_ids stay index-aligned for the client's mapping.
node_ids = [n.id for n in created_nodes]
@@ -663,31 +701,52 @@ async def approve_device(
raise HTTPException(status_code=409, detail=conflict)
device.status = "approved"
# Prefer the MAC discovered during the scan (stored on the pending device);
# Prefer the MAC discovered during the scan (stored on the inventory row);
# fall back to whatever the approve payload carried.
_mac = device.mac or node_data.mac
cluster_host = await _is_proxmox_cluster_member(db, device.ieee_address)
node = Node(
label=node_data.label,
type=node_data.type,
ip=node_data.ip,
mac=_mac,
hostname=node_data.hostname,
status="online" if wireless else node_data.status,
services=node_data.services or [],
ieee_address=device.ieee_address,
properties=_wireless_properties(
# The approve dialog is an edit of the device, so its values land on the row
# — the node that follows only says where it is drawn. A blank field in the
# payload never clears what discovery already found.
device.label = node_data.label or device.label
device.type = node_data.type or device.type
device.ip = node_data.ip or device.ip
device.mac = _mac
device.hostname = node_data.hostname or device.hostname
device.services = merge_services(device.services, node_data.services)
device.properties = (
_wireless_properties(
node_data.type, device.ieee_address, device.vendor, device.model, device.lqi
) if wireless else merge_mac_property(
)
if wireless
else merge_mac_property(
merge_zigbee_properties(list(device.properties or []), node_data.properties or []),
_mac,
),
check_method="none" if wireless else (node_data.check_method or ("ping" if node_data.ip else None)),
check_target=None if wireless else node_data.check_target,
# Cluster hosts get side handles for their host↔host cluster edge.
)
)
if wireless:
# A mesh device answers no ICMP; being in the mesh is the liveness.
device.check_method = "none"
device.check_target = None
device.status_live = "online"
else:
device.check_method = node_data.check_method or device.check_method or (
"ping" if device.ip else None
)
device.check_target = node_data.check_target or device.check_target
if node_data.status:
device.status_live = node_data.status
node = Node(
label=device.label or node_data.label,
type=device.type or node_data.type,
# Cluster hosts get side handles for their host<->host cluster edge.
left_handles=1 if cluster_host else 0,
right_handles=1 if cluster_host else 0,
design_id=node_design_id,
# The node draws this inventory row; the row owns the facts.
device_id=device.id,
)
db.add(node)
await db.flush()
@@ -760,13 +819,16 @@ async def _resolve_pending_links_for_ieee(
for link in links
}
other_ieees.add(ieee)
# A node reaches its ieee through the inventory row it draws.
nodes_q = await db.execute(
select(Node).where(
Node.ieee_address.in_(other_ieees),
select(InventoryDevice.ieee_address, Node)
.join(Node, Node.device_id == InventoryDevice.id)
.where(
InventoryDevice.ieee_address.in_(other_ieees),
Node.design_id == design_id,
)
)
by_ieee = {n.ieee_address: n for n in nodes_q.scalars().all() if n.ieee_address}
by_ieee = {row_ieee: node for row_ieee, node in nodes_q.all() if row_ieee}
self_node = by_ieee.get(ieee)
if self_node is None:
+12 -2
View File
@@ -4,13 +4,19 @@ from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.core.config import settings
from app.core.scheduler import reschedule_service_checks, set_service_checks_enabled
from app.core.scheduler import (
reschedule_service_checks,
reschedule_status_checks,
set_service_checks_enabled,
)
router = APIRouter()
class AppSettings(BaseModel):
interval_seconds: int
# Mirrors the floor enforced by reschedule_status_checks(): rejecting here
# returns 422 instead of persisting the value and then failing with a 500.
interval_seconds: int = Field(ge=10)
service_check_enabled: bool = False
service_check_interval: int = Field(default=300, ge=30)
@@ -33,6 +39,10 @@ async def update_settings(
settings.service_check_enabled = payload.service_check_enabled
settings.service_check_interval = payload.service_check_interval
settings.save_overrides()
# Apply the status-check schedule live. Without this the new interval is
# persisted and shown in the UI, but the running job keeps the old one
# until the backend restarts.
reschedule_status_checks(payload.interval_seconds)
# Apply the service-check schedule live.
set_service_checks_enabled(payload.service_check_enabled)
if payload.service_check_enabled:
+19 -3
View File
@@ -30,10 +30,23 @@ async def summary(
"""
_check_key(x_api_key)
# A node's reachability lives on the Device Inventory row it draws, so the
# per-canvas counts come from the join. A node with no row (canvas
# furniture) counts as unknown.
status_rows = (
await db.execute(select(Node.status, func.count()).group_by(Node.status))
await db.execute(
select(InventoryDevice.status_live, func.count())
.select_from(Node)
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
.group_by(InventoryDevice.status_live)
)
).all()
counts = {row[0]: row[1] for row in status_rows}
counts = {(row[0] or "unknown"): row[1] for row in status_rows}
unlinked = (
await db.execute(select(func.count()).select_from(Node).where(Node.device_id.is_(None)))
).scalar_one()
if unlinked:
counts["unknown"] = counts.get("unknown", 0) + unlinked
pending = (
await db.execute(
@@ -45,7 +58,10 @@ async def summary(
zigbee = (
await db.execute(
select(func.count()).select_from(Node).where(Node.ieee_address.isnot(None))
select(func.count())
.select_from(Node)
.join(InventoryDevice, Node.device_id == InventoryDevice.id)
.where(InventoryDevice.ieee_address.isnot(None))
)
).scalar_one()
+20 -4
View File
@@ -71,20 +71,36 @@ async def _broadcast(payload: str) -> None:
_drop(conn)
async def broadcast_status(node_id: str, status: str, checked_at: str, response_time_ms: int | None = None) -> None:
async def broadcast_status(
device_id: str,
node_ids: list[str],
status: str,
checked_at: str,
response_time_ms: int | None = None,
) -> None:
"""Report one device's reachability to every canvas drawing it.
A device is checked once, not once per canvas, so the result is addressed by
device and carries the nodes it lights up. ``node_ids`` is empty for a
device that is in the inventory but on no canvas.
"""
await _broadcast(json.dumps({
"type": "status",
"node_id": node_id,
"device_id": device_id,
"node_ids": node_ids,
"status": status,
"checked_at": checked_at,
"response_time_ms": response_time_ms,
}))
async def broadcast_service_status(node_id: str, services: list[dict[str, object]], checked_at: str) -> None:
async def broadcast_service_status(
device_id: str, node_ids: list[str], services: list[dict[str, object]], checked_at: str
) -> None:
await _broadcast(json.dumps({
"type": "service_status",
"node_id": node_id,
"device_id": device_id,
"node_ids": node_ids,
"services": services,
"checked_at": checked_at,
}))
+21 -56
View File
@@ -27,7 +27,7 @@ from app.schemas.zigbee import (
ZigbeeTestConnectionRequest,
ZigbeeTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.node_dedupe import dedupe_nodes_by_device
from app.services.zigbee_service import (
build_zigbee_properties,
fetch_networkmap,
@@ -36,6 +36,15 @@ from app.services.zigbee_service import (
)
logger = logging.getLogger(__name__)
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
"""True while at least one canvas node still draws this device."""
return (
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
).scalar_one_or_none() is not None
router = APIRouter()
@@ -192,7 +201,7 @@ async def _persist_pending_import(
"""
# Repair any pre-existing duplicate nodes (same IEEE) before upserting, so
# the by-IEEE lookups below resolve to a single row.
await dedupe_nodes_by_ieee(db)
await dedupe_nodes_by_device(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
@@ -213,55 +222,10 @@ async def _persist_pending_import(
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# If the device has already been approved as a canvas Node, refresh its
# properties on every canvas it sits on. Still ensure the discovery
# inventory carries a row for it (status="approved") so it shows in the
# inventory list with an "In N canvas" badge — legacy auto-placed
# coordinators never got a pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zigbee_properties(
existing_node.properties, props
)
inv = (
await db.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zigbee",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status (an approved
# device stays approved; a hidden one stays hidden).
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
# Properties belong to the inventory row now, so one upsert serves
# every canvas drawing this device — there is no per-node refresh left
# to do. The row's status is never touched here: an approved device
# stays approved, a hidden one stays hidden.
result = await db.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
@@ -277,6 +241,7 @@ async def _persist_pending_import(
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
properties=props,
status="pending",
discovery_source="zigbee",
)
@@ -290,11 +255,11 @@ async def _persist_pending_import(
pending.vendor = n.get("vendor") or pending.vendor
if n.get("lqi") is not None:
pending.lqi = n.get("lqi")
if pending.status == "approved":
# The device was approved earlier but its canvas Node no longer
# exists (no Node matched the IEEE above) — it was deleted. Revive
# the row to "pending" so it reappears in the Pending list on
# re-import instead of being silently swallowed. (Issue #167)
pending.properties = merge_zigbee_properties(list(pending.properties or []), props)
if pending.status == "approved" and not await _is_drawn(db, pending.id):
# Approved earlier but no canvas draws it any more — the node was
# deleted. Revive the row to "pending" so it reappears in the list
# on re-import instead of being silently swallowed. (Issue #167)
pending.status = "pending"
elif pending.status == "hidden":
# Re-imported a hidden device → leave it hidden, just refresh fields.
+20 -53
View File
@@ -27,7 +27,7 @@ from app.schemas.zwave import (
ZwaveTestConnectionRequest,
ZwaveTestConnectionResponse,
)
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.services.node_dedupe import dedupe_nodes_by_device
from app.services.zwave_service import (
build_zwave_properties,
fetch_zwave_network,
@@ -36,6 +36,15 @@ from app.services.zwave_service import (
)
logger = logging.getLogger(__name__)
async def _is_drawn(db: AsyncSession, device_id: str) -> bool:
"""True while at least one canvas node still draws this device."""
return (
await db.execute(select(Node.id).where(Node.device_id == device_id).limit(1))
).scalar_one_or_none() is not None
router = APIRouter()
@@ -189,7 +198,7 @@ async def _persist_pending_import(
"""
# Repair any pre-existing same-canvas duplicate nodes before upserting, so
# the by-IEEE lookups below resolve cleanly.
await dedupe_nodes_by_ieee(db)
await dedupe_nodes_by_device(db)
# Coordinator is no longer auto-placed, so the response's coordinator fields
# stay unset — retained for backward-compatible response shape.
@@ -208,54 +217,10 @@ async def _persist_pending_import(
# the pending inventory like every other device, so the user approves it
# explicitly. Only the shared paths below run for it.
# Already approved as a canvas Node → refresh props on every canvas it
# sits on. Still ensure the discovery inventory carries a row for it
# (status="approved") so it shows in the inventory list with an
# "In N canvas" badge — legacy auto-placed coordinators never got a
# pending row, which is why they went missing.
existing_nodes = (
await db.execute(
select(Node).where(Node.ieee_address == ieee).order_by(Node.id)
)
).scalars().all()
if existing_nodes:
for existing_node in existing_nodes:
existing_node.properties = merge_zwave_properties(
existing_node.properties, props
)
inv = (
await db.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
).scalar_one_or_none()
if inv is None:
db.add(
InventoryDevice(
ieee_address=ieee,
friendly_name=n.get("friendly_name"),
hostname=n.get("friendly_name"),
suggested_type=n.get("type"),
device_subtype=n.get("device_type"),
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
status="approved",
discovery_source="zwave",
)
)
pending_created += 1
else:
# Refresh metadata but never change the row's status.
inv.friendly_name = n.get("friendly_name") or inv.friendly_name
inv.suggested_type = n.get("type") or inv.suggested_type
inv.device_subtype = n.get("device_type") or inv.device_subtype
inv.model = n.get("model") or inv.model
inv.vendor = n.get("vendor") or inv.vendor
if n.get("lqi") is not None:
inv.lqi = n.get("lqi")
pending_updated += 1
continue
# Properties belong to the inventory row now, so one upsert serves
# every canvas drawing this device — there is no per-node refresh left
# to do. The row's status is never touched here: an approved device
# stays approved, a hidden one stays hidden.
result = await db.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == ieee)
)
@@ -271,6 +236,7 @@ async def _persist_pending_import(
model=n.get("model"),
vendor=n.get("vendor"),
lqi=n.get("lqi"),
properties=props,
status="pending",
discovery_source="zwave",
)
@@ -282,9 +248,10 @@ async def _persist_pending_import(
pending.device_subtype = n.get("device_type") or pending.device_subtype
pending.model = n.get("model") or pending.model
pending.vendor = n.get("vendor") or pending.vendor
if pending.status == "approved":
# Approved earlier but the canvas Node is gone (deleted) — revive
# to "pending" so it reappears in the list instead of vanishing.
pending.properties = merge_zwave_properties(list(pending.properties or []), props)
if pending.status == "approved" and not await _is_drawn(db, pending.id):
# Approved earlier but no canvas draws it any more (the node was
# deleted) — revive to "pending" so it reappears in the list.
pending.status = "pending"
elif pending.status == "hidden":
pass
+73 -32
View File
@@ -7,10 +7,11 @@ from typing import TYPE_CHECKING, Any
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.database import AsyncSessionLocal
from app.db.models import Node
from app.db.models import InventoryDevice, Node
from app.services.status_checker import check_node, check_services
if TYPE_CHECKING:
@@ -22,13 +23,23 @@ logger = logging.getLogger(__name__)
scheduler: AsyncIOScheduler = AsyncIOScheduler()
async def _check_single_node(
node_id: str,
async def _nodes_by_device(db: AsyncSession) -> dict[str, list[str]]:
"""Map each device id to the nodes drawing it, across every canvas."""
rows = (await db.execute(select(Node.id, Node.device_id).where(Node.device_id.is_not(None)))).all()
out: dict[str, list[str]] = {}
for node_id, device_id in rows:
out.setdefault(device_id, []).append(node_id)
return out
async def _check_single_device(
device_id: str,
node_ids: list[str],
check_method: str,
check_target: str | None,
ip: str | None,
) -> tuple[str, dict[str, object] | None]:
"""Run a single node check; returns (node_id, result_or_None).
"""Run a single device check; returns (device_id, result_or_None).
Accepts plain scalars — not an ORM object — so there is no risk of
DetachedInstanceError when the originating session has already closed.
@@ -37,45 +48,67 @@ async def _check_single_node(
try:
check_result = await check_node(check_method, check_target, ip)
raw_ms = check_result["response_time_ms"]
response_ms = raw_ms if isinstance(raw_ms, int) else None
now = datetime.now(timezone.utc)
async with AsyncSessionLocal() as db:
n = await db.get(Node, node_id)
if n:
n.status = check_result["status"]
n.response_time_ms = check_result["response_time_ms"]
device = await db.get(InventoryDevice, device_id)
if device:
device.status_live = str(check_result["status"])
device.response_time_ms = response_ms
if check_result["status"] == "online":
n.last_seen = now
device.last_seen = now
await db.commit()
await broadcast_status(
node_id=node_id,
status=check_result["status"],
device_id=device_id,
node_ids=node_ids,
status=str(check_result["status"]),
checked_at=now.isoformat(),
response_time_ms=check_result["response_time_ms"],
response_time_ms=response_ms,
)
return node_id, check_result
return device_id, check_result
except Exception as exc:
logger.error("Status check failed for node %s: %s", node_id, exc)
return node_id, None
logger.error("Status check failed for device %s: %s", device_id, exc)
return device_id, None
async def _run_status_checks() -> None:
"""Check all nodes concurrently and broadcast results via WebSocket."""
"""Check every device once and broadcast the result to every canvas.
Device-scoped, not node-scoped: a host drawn on three canvases used to be
pinged three times and could report three different states. Hidden devices
are skipped — hiding one is how a user says "stop showing me this".
A device with no node at all is still checked, deliberately. The inventory
row owns the device and shows its live status in the list and the detail
modal, so a rack mount or an inventory-only entry reports state without
being drawn anywhere — and deleting a node no longer silently stops
monitoring the host. `hide` (or clearing the check method) is what ends the
checks; the broadcast simply carries an empty `node_ids`.
"""
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
devices = (
await db.execute(
select(InventoryDevice).where(
InventoryDevice.check_method.is_not(None),
InventoryDevice.check_method != "",
InventoryDevice.status != "hidden",
)
)
).scalars().all()
node_map = await _nodes_by_device(db)
# Extract scalars while the session is open to avoid DetachedInstanceError
checkable = [
(n.id, n.check_method, n.check_target, n.ip)
for n in nodes
if n.check_method
(d.id, node_map.get(d.id, []), d.check_method or "ping", d.check_target, d.ip)
for d in devices
]
if not checkable:
return
await asyncio.gather(*[
_check_single_node(node_id, method, target, ip)
for node_id, method, target, ip in checkable
_check_single_device(device_id, node_ids, method, target, ip)
for device_id, node_ids, method, target, ip in checkable
])
@@ -89,27 +122,35 @@ def _node_host(ip: str | None, hostname: str | None) -> str | None:
async def _run_service_checks() -> None:
"""Check every service of every node and broadcast per-service results."""
"""Check every service of every device and broadcast per-service results.
Device-scoped for the same reason as the status check: the services belong
to the device, so one pass serves every canvas showing it.
"""
if not settings.service_check_enabled:
return
from app.api.routes.status import broadcast_service_status # avoid circular import
async with AsyncSessionLocal() as db:
result = await db.execute(select(Node))
nodes = result.scalars().all()
devices = (
await db.execute(select(InventoryDevice).where(InventoryDevice.status != "hidden"))
).scalars().all()
node_map = await _nodes_by_device(db)
checkable = [
(n.id, _node_host(n.ip, n.hostname), list(n.services or []))
for n in nodes
if n.services
(d.id, node_map.get(d.id, []), _node_host(d.ip, d.hostname), list(d.services or []))
for d in devices
if d.services
]
now = datetime.now(timezone.utc).isoformat()
for node_id, host, services in checkable:
for device_id, node_ids, host, services in checkable:
try:
statuses = await check_services(host, services)
await broadcast_service_status(node_id=node_id, services=statuses, checked_at=now)
await broadcast_service_status(
device_id=device_id, node_ids=node_ids, services=statuses, checked_at=now
)
except Exception as exc:
logger.error("Service checks failed for node %s: %s", node_id, exc)
logger.error("Service checks failed for device %s: %s", device_id, exc)
async def _run_proxmox_sync() -> None:
+5
View File
@@ -204,6 +204,11 @@
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Syncthing", "service_name": "Syncthing", "icon": "refresh-cw", "category": "storage", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Gitea", "service_name": "Gitea", "icon": "git-branch", "category": "dev", "suggested_node_type": "server"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "[Pp]i[Kk]VM", "service_name": "PiKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "TinyPilot", "service_name": "TinyPilot", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "JetKVM", "service_name": "JetKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "NanoKVM", "service_name": "NanoKVM", "icon": "monitorcog", "category": "kvm", "suggested_node_type": "kvm"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "openmediavault", "service_name": "OpenMediaVault", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Unraid", "service_name": "Unraid", "icon": "hard-drive", "category": "nas", "suggested_node_type": "nas"},
{"port": null, "protocol": "tcp", "banner_regex": null, "http_regex": "Cockpit", "service_name": "Cockpit", "icon": "monitor", "category": "nas", "suggested_node_type": "server"}
+224 -1
View File
@@ -5,8 +5,9 @@ import uuid as _uuid_mod
from collections.abc import AsyncGenerator
from contextlib import suppress
from pathlib import Path
from typing import Any
from sqlalchemy.exc import OperationalError
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
@@ -426,6 +427,228 @@ async def init_db() -> None:
await conn.exec_driver_sql(
"UPDATE nodes SET mac = lower(replace(mac, '-', ':')) WHERE mac IS NOT NULL"
)
# 3.3.0 — the inventory row owns the device facts, the canvas node only
# owns how it is drawn. These columns receive what used to live solely on
# `nodes`. Additive here; `nodes` keeps its copies until the backfill has
# run and been verified.
inventory_device_migrations: list[tuple[str, str]] = [
("device_inventory.label", "ALTER TABLE device_inventory ADD COLUMN label TEXT"),
("device_inventory.type", "ALTER TABLE device_inventory ADD COLUMN type TEXT"),
("device_inventory.notes", "ALTER TABLE device_inventory ADD COLUMN notes TEXT"),
("device_inventory.cpu_count", "ALTER TABLE device_inventory ADD COLUMN cpu_count INTEGER"),
("device_inventory.cpu_model", "ALTER TABLE device_inventory ADD COLUMN cpu_model TEXT"),
("device_inventory.ram_gb", "ALTER TABLE device_inventory ADD COLUMN ram_gb FLOAT"),
("device_inventory.disk_gb", "ALTER TABLE device_inventory ADD COLUMN disk_gb FLOAT"),
(
"device_inventory.show_hardware",
"ALTER TABLE device_inventory ADD COLUMN show_hardware BOOLEAN DEFAULT 0",
),
("device_inventory.check_method", "ALTER TABLE device_inventory ADD COLUMN check_method TEXT"),
("device_inventory.check_target", "ALTER TABLE device_inventory ADD COLUMN check_target TEXT"),
(
"device_inventory.status_live",
"ALTER TABLE device_inventory ADD COLUMN status_live TEXT DEFAULT 'unknown'",
),
("device_inventory.last_seen", "ALTER TABLE device_inventory ADD COLUMN last_seen DATETIME"),
("device_inventory.last_scan", "ALTER TABLE device_inventory ADD COLUMN last_scan DATETIME"),
(
"device_inventory.response_time_ms",
"ALTER TABLE device_inventory ADD COLUMN response_time_ms INTEGER",
),
("device_inventory.updated_at", "ALTER TABLE device_inventory ADD COLUMN updated_at DATETIME"),
]
for label, sql in inventory_device_migrations:
await _try_migrate(conn, sql, label=label)
# Backfill the columns that carry a non-NULL default in the model, so a
# legacy row round-trips through Pydantic without tripping the validators.
# The link itself: a node points at the inventory row it draws.
for label, sql in (
("nodes.device_id", "ALTER TABLE nodes ADD COLUMN device_id TEXT"),
(
"nodes.device_id.index",
"CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)",
),
):
await _try_migrate(conn, sql, label=label)
for label, sql in (
(
"device_inventory.status_live.backfill",
"UPDATE device_inventory SET status_live = 'unknown' WHERE status_live IS NULL",
),
(
"device_inventory.show_hardware.backfill",
"UPDATE device_inventory SET show_hardware = 0 WHERE show_hardware IS NULL",
),
(
"device_inventory.updated_at.backfill",
"UPDATE device_inventory SET updated_at = discovered_at WHERE updated_at IS NULL",
),
):
await _try_migrate(conn, sql, label=label)
await _backfill_node_devices()
await _drop_legacy_node_columns()
# Columns `nodes` carried before 3.3.0, when a node owned the device facts. They
# belong to `device_inventory` now; the backfill above copies them across, and
# this rebuild removes them.
_LEGACY_NODE_COLUMNS = (
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
"response_time_ms",
)
# What a node keeps: how the device is drawn on one canvas.
_NODE_COLUMNS_SQL = (
"id VARCHAR PRIMARY KEY,"
"type VARCHAR NOT NULL,"
"label VARCHAR NOT NULL,"
"design_id VARCHAR REFERENCES designs(id) ON DELETE SET NULL,"
"device_id VARCHAR REFERENCES device_inventory(id) ON DELETE SET NULL,"
"pos_x FLOAT,"
"pos_y FLOAT,"
"parent_id VARCHAR REFERENCES nodes(id) ON DELETE CASCADE,"
"container_mode BOOLEAN,"
"custom_colors JSON,"
"custom_icon VARCHAR,"
"show_port_numbers BOOLEAN,"
"width FLOAT,"
"height FLOAT,"
"bottom_handles INTEGER,"
"top_handles INTEGER,"
"left_handles INTEGER,"
"right_handles INTEGER,"
"created_at DATETIME,"
"updated_at DATETIME"
)
_NODE_KEPT = (
"id, type, label, design_id, device_id, pos_x, pos_y, parent_id, container_mode, "
"custom_colors, custom_icon, show_port_numbers, width, height, bottom_handles, "
"top_handles, left_handles, right_handles, created_at, updated_at"
)
async def _relax_legacy_node_columns(conn: AsyncConnection, info: list[Any]) -> None:
"""Make the retained legacy `nodes` columns nullable — SQLite table rebuild.
The 3.2.0 schema declares `status`, `services`, `properties` and
`show_hardware` NOT NULL with no server-side default. The 3.3.0 model no
longer maps them, so every INSERT omits them and SQLite rejects the row —
approving a device, creating a node, importing a canvas all fail with
``NOT NULL constraint failed: nodes.status``. Dropping the columns is the
real fix, but it waits on a complete backfill; until then they have to stop
blocking writes. Values are preserved: only the constraint goes.
"""
kept = [row for row in info if row[1] in _LEGACY_NODE_COLUMNS]
if not any(row[3] for row in kept): # PRAGMA `notnull`
return # Already relaxed, or never constrained.
logger.info("Relaxing NOT NULL on the retained legacy node columns")
legacy_defs = ",".join(f"{row[1]} {row[2] or 'VARCHAR'}" for row in kept)
legacy_names = ", ".join(row[1] for row in kept)
await _rebuild_nodes(
conn,
columns_sql=f"{_NODE_COLUMNS_SQL},{legacy_defs}",
copied=f"{_NODE_KEPT}, {legacy_names}",
what="nodes legacy-column relax",
)
async def _rebuild_nodes(conn: AsyncConnection, *, columns_sql: str, copied: str, what: str) -> None:
"""Recreate `nodes` with a new column list — SQLite cannot alter constraints.
Never fatal: a rebuild that fails leaves the table it could not replace, and
the boot carries on. Foreign keys go off for the swap, because `edges` and
`rack_devices` point at `nodes`, and back on in every case — the pragma is
per connection and this one returns to the pool.
"""
try:
await conn.exec_driver_sql("PRAGMA foreign_keys = OFF")
# A previous attempt that failed after the create would block this one.
await conn.exec_driver_sql("DROP TABLE IF EXISTS nodes_new")
await conn.exec_driver_sql(f"CREATE TABLE nodes_new ({columns_sql})")
await conn.exec_driver_sql(f"INSERT INTO nodes_new ({copied}) SELECT {copied} FROM nodes")
await conn.exec_driver_sql("DROP TABLE nodes")
await conn.exec_driver_sql("ALTER TABLE nodes_new RENAME TO nodes")
await conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_nodes_device_id ON nodes(device_id)")
except (OperationalError, IntegrityError) as exc:
logger.warning("%s failed: %s", what, exc)
finally:
await conn.exec_driver_sql("PRAGMA foreign_keys = ON")
async def _drop_legacy_node_columns() -> None:
"""Remove the device columns from `nodes` (3.3.0) — SQLite table rebuild.
Runs only after the backfill has linked every device node to its inventory
row. If any non-furniture node is still unlinked the drop is skipped and
logged: the columns are the only remaining copy of that node's facts, and
losing them is not recoverable. The skip then relaxes their NOT NULL instead,
because the 3.3.0 model no longer writes them and the database must stay
insertable while the backfill is retried on later boots.
"""
async with engine.begin() as conn:
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
present = {row[1] for row in info}
if not (present & set(_LEGACY_NODE_COLUMNS)):
return # Already migrated.
unlinked = (
await conn.exec_driver_sql(
"SELECT COUNT(*) FROM nodes WHERE device_id IS NULL "
"AND type NOT IN ('group', 'groupRect', 'text')"
)
).scalar()
if unlinked:
logger.warning(
"Keeping the legacy node columns: %d node(s) have no inventory row. "
"The backfill must link every device node before they can be dropped.",
unlinked,
)
await _relax_legacy_node_columns(conn, list(info))
return
logger.info("Migrating nodes: the device columns move to device_inventory")
await _rebuild_nodes(
conn,
columns_sql=_NODE_COLUMNS_SQL,
copied=_NODE_KEPT,
what="nodes device-column drop",
)
async def _backfill_node_devices() -> None:
"""Link existing canvas nodes to their Device Inventory rows (3.3.0).
Runs outside the DDL connection because the merge is Python-side, not SQL.
Self-limiting: it only looks at nodes with no ``device_id``, so a second boot
finds nothing to do. Never fatal — a failure here leaves the canvas working
off its own columns, which still carry the data at this point.
"""
# Imported here: app.services imports app.db.models, which imports this module.
from app.services.inventory_sync import backfill_node_devices
try:
async with AsyncSessionLocal() as session:
stats = await backfill_node_devices(session)
if stats["linked"]:
await session.commit()
logger.info(
"Inventory backfill: linked %d node(s) — %d device(s) created, %d merged",
stats["linked"], stats["created"], stats["merged"],
)
if stats.get("skipped"):
logger.warning(
"Inventory backfill: %d node(s) could not be linked; they keep their "
"legacy columns and are retried on the next start.",
stats["skipped"],
)
except Exception as exc: # pragma: no cover - defensive, boot must not die
logger.warning("Inventory backfill failed: %s", exc)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
+41 -19
View File
@@ -28,44 +28,40 @@ class Design(Base):
class Node(Base):
"""How a device is drawn on one canvas.
A node owns presentation only — position, size, colours, icon, handles,
nesting. What the device *is* (addresses, services, properties, notes,
hardware, check method, live status) belongs to the `device_inventory` row
named by `device_id`, so one device reads the same on every canvas. The API
still reports both together: see `services/inventory_sync.hydrated_node`.
"""
__tablename__ = "nodes"
id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
type: Mapped[str] = mapped_column(String, nullable=False)
label: Mapped[str] = mapped_column(String, nullable=False)
design_id: Mapped[str | None] = mapped_column(String, ForeignKey("designs.id", ondelete="SET NULL"), nullable=True)
hostname: Mapped[str | None] = mapped_column(String)
ip: Mapped[str | None] = mapped_column(String)
mac: Mapped[str | None] = mapped_column(String)
os: Mapped[str | None] = mapped_column(String)
status: Mapped[str] = mapped_column(String, default="unknown")
check_method: Mapped[str | None] = mapped_column(String)
check_target: Mapped[str | None] = mapped_column(String)
services: Mapped[list[Any]] = mapped_column(JSON, default=list)
notes: Mapped[str | None] = mapped_column(Text)
# The Device Inventory row this node draws. NULL for canvas furniture
# (group / groupRect / text), which represents nothing physical. Deleting a
# node never deletes the device — the inventory outlives every canvas.
device_id: Mapped[str | None] = mapped_column(
String, ForeignKey("device_inventory.id", ondelete="SET NULL"), index=True, nullable=True
)
pos_x: Mapped[float] = mapped_column(Float, default=0)
pos_y: Mapped[float] = mapped_column(Float, default=0)
parent_id: Mapped[str | None] = mapped_column(String, ForeignKey("nodes.id", ondelete="CASCADE"))
container_mode: Mapped[bool] = mapped_column(Boolean, default=False)
custom_colors: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
custom_icon: Mapped[str | None] = mapped_column(String, nullable=True)
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
show_port_numbers: Mapped[bool] = mapped_column(Boolean, default=False)
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
width: Mapped[float | None] = mapped_column(Float, nullable=True)
height: Mapped[float | None] = mapped_column(Float, nullable=True)
bottom_handles: Mapped[int] = mapped_column(Integer, default=1)
top_handles: Mapped[int] = mapped_column(Integer, default=1)
left_handles: Mapped[int] = mapped_column(Integer, default=0)
right_handles: Mapped[int] = mapped_column(Integer, default=0)
ieee_address: Mapped[str | None] = mapped_column(String, index=True, nullable=True)
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
response_time_ms: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now, onupdate=_now)
children: Mapped[list["Node"]] = relationship("Node", back_populates="parent")
@@ -236,6 +232,32 @@ class InventoryDevice(Base):
properties: Mapped[list[Any]] = mapped_column(JSON, default=list)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# --- Curated device facts (3.3.0) -------------------------------------
# The inventory row, not the canvas node, owns what the device *is*. A node
# only says how it is drawn. `label`/`type` supersede friendly_name/
# suggested_type for a device that reached a canvas; the older pair is kept
# so discovery imports and the source filters keep working unchanged.
label: Mapped[str | None] = mapped_column(String, nullable=True)
type: Mapped[str | None] = mapped_column(String, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
cpu_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str | None] = mapped_column(String, nullable=True)
ram_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
disk_gb: Mapped[float | None] = mapped_column(Float, nullable=True)
show_hardware: Mapped[bool] = mapped_column(Boolean, default=False)
check_method: Mapped[str | None] = mapped_column(String, nullable=True)
check_target: Mapped[str | None] = mapped_column(String, nullable=True)
# Live reachability from the status checker. NOT `status` — that column holds
# the inventory lifecycle (pending/approved/hidden) and the two must not be
# conflated.
status_live: Mapped[str] = mapped_column(String, default="unknown")
last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_scan: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
response_time_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
# Transient (not persisted): populated per-request by the scan routes to report
# how many canvases this device already appears on. Not a mapped column.
canvas_count: int = 0
+11
View File
@@ -11,6 +11,17 @@ class NodeSave(BaseModel):
id: str
type: str
label: str
# Round-tripped so a save keeps the node pointed at its inventory row. The
# device fields below are still accepted at their canvas values and routed
# to that row server-side.
device_id: str | None = None
# Which device facts this canvas edited since it loaded them, if the client
# tracked that. The payload always carries a *full* copy of the device — it
# was hydrated from the inventory row on load — so without this a save made
# for nothing but a moved node would rewrite the row from a snapshot that may
# be hours old. `None` (an older client, an import, the MCP server) keeps the
# previous behaviour: every fact is a candidate write.
changed_facts: list[str] | None = None
hostname: str | None = None
ip: str | None = None
mac: str | None = None
+4
View File
@@ -85,6 +85,10 @@ class NodeUpdate(BaseModel):
class NodeResponse(NodeBase):
id: str
design_id: str | None = None
# The Device Inventory row this node draws; None for canvas furniture. The
# device fields above are hydrated from that row on read, so the wire shape
# is unchanged even though the row, not the node, owns them.
device_id: str | None = None
ieee_address: str | None = None
last_seen: datetime | None = None
last_scan: datetime | None = None
+80 -1
View File
@@ -27,6 +27,26 @@ class InventoryDeviceResponse(BaseModel):
# the node on approve; empty for scan/mesh sources that don't set them.
properties: list[Any] = []
discovered_at: datetime
# Curated device facts (3.3.0). `label`/`type` supersede friendly_name/
# suggested_type once the user has named the device; the older pair stays for
# discovery imports and the source filters.
label: str | None = None
type: str | None = None
notes: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
check_method: str | None = None
check_target: str | None = None
# Live reachability, distinct from `status` (the pending/approved/hidden
# lifecycle).
status_live: str = "unknown"
last_seen: datetime | None = None
last_scan: datetime | None = None
response_time_ms: int | None = None
updated_at: datetime | None = None
# Number of distinct canvases (designs) this device already appears on,
# correlated by ip / ieee_address against existing nodes. Computed per-request.
canvas_count: int = 0
@@ -38,12 +58,22 @@ class InventoryDeviceResponse(BaseModel):
node_last_modified: datetime | None = None
node_last_seen: datetime | None = None
@field_validator("properties", "discovery_sources", mode="before")
@field_validator("properties", "discovery_sources", "services", mode="before")
@classmethod
def _coerce_list(cls, v: Any) -> list[Any]:
# Legacy rows (columns added by migration) have these = NULL.
return v if isinstance(v, list) else []
@field_validator("status_live", mode="before")
@classmethod
def _coerce_status_live(cls, v: Any) -> str:
return v if isinstance(v, str) and v else "unknown"
@field_validator("show_hardware", mode="before")
@classmethod
def _coerce_show_hardware(cls, v: Any) -> bool:
return bool(v)
model_config = {"from_attributes": True}
@@ -73,6 +103,22 @@ class InventoryDeviceCreate(BaseModel):
vendor: str | None = None
properties: list[Any] = []
discovery_source: str = "manual"
# Curated fields, so a hand-made entry can carry everything the edit modal
# shows rather than needing a create-then-PATCH round trip.
os: str | None = None
services: list[Any] = []
friendly_name: str | None = None
device_subtype: str | None = None
label: str | None = None
type: str | None = None
notes: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool = False
check_method: str | None = None
check_target: str | None = None
@field_validator("discovery_source")
@classmethod
@@ -82,6 +128,39 @@ class InventoryDeviceCreate(BaseModel):
return v
class InventoryDeviceUpdate(BaseModel):
"""Partial edit of an inventory row — the write half of the detail modal.
Every field is optional and only what the client sends is applied
(`exclude_unset`), so a caller editing one field never clears the rest.
Lifecycle (`status`) and discovery bookkeeping (`discovery_source(s)`,
`discovered_at`, `ieee_address`) are deliberately not editable here: those
are owned by the approve/hide routes and the importers.
"""
ip: str | None = None
mac: str | None = None
hostname: str | None = None
os: str | None = None
label: str | None = None
type: str | None = None
suggested_type: str | None = None
friendly_name: str | None = None
device_subtype: str | None = None
model: str | None = None
vendor: str | None = None
services: list[Any] | None = None
properties: list[Any] | None = None
notes: str | None = None
cpu_count: int | None = None
cpu_model: str | None = None
ram_gb: float | None = None
disk_gb: float | None = None
show_hardware: bool | None = None
check_method: str | None = None
check_target: str | None = None
class ScanRunResponse(BaseModel):
id: str
status: str
+6 -2
View File
@@ -213,12 +213,16 @@ def suggest_node_type(open_ports: list[dict[str, Any]], mac: str | None = None)
if mac_type == "iot":
return "iot"
priority = ["proxmox", "nas", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch"]
priority = [
"proxmox", "nas", "kvm", "router", "lxc", "vm", "ap", "camera", "iot", "server", "switch",
]
found: set[str] = set()
for p in open_ports:
port = p["port"]
proto = p.get("protocol", "tcp")
sig = match_port(port, proto)
# Pass the HTTP-probe signals through: port-agnostic signatures (port: null,
# e.g. PiKVM, Unraid) match on title/headers alone and are invisible otherwise.
sig = match_service(port, proto, p.get("banner"), p.get("http_signals"))
if sig and sig.get("suggested_node_type"):
found.add(sig["suggested_node_type"])
if port in _PORT_TYPE_HINTS:
+631
View File
@@ -0,0 +1,631 @@
"""Keep a canvas :class:`Node` and its Device Inventory row in step.
The inventory row owns what a device *is* — addresses, services, properties,
notes, hardware, check method. A node owns only how that device is drawn on one
canvas. This module holds the matching and merging rules shared by:
* the one-off backfill that links pre-3.3.0 nodes to inventory rows,
* the approve / node-create paths, which link instead of copying,
* the canvas save write-through, which pushes a node edit back to the row.
Nothing here commits — the caller owns the transaction.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from typing import Any
from sqlalchemy import func, or_, select, text
from sqlalchemy.exc import IntegrityError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import InventoryDevice, Node
from app.services.discovery_sources import add_source
logger = logging.getLogger(__name__)
# Canvas furniture: annotations, not hardware. These never get an inventory row.
FURNITURE_TYPES = frozenset({"group", "groupRect", "text"})
# Source tag for a device that only ever existed as a canvas node — the backfill
# mints its inventory row.
CANVAS_SOURCE = "canvas"
# Scalar facts the inventory row owns. Order matters only for readability.
DEVICE_SCALARS = (
"hostname",
"ip",
"mac",
"os",
"notes",
"cpu_count",
"cpu_model",
"ram_gb",
"disk_gb",
"check_method",
"check_target",
)
def is_furniture(node_type: str | None) -> bool:
return (node_type or "") in FURNITURE_TYPES
def _ip_tokens(ip: str | None) -> list[str]:
"""Split an ``ip`` field into individual addresses.
A node or device may carry several comma-separated addresses, so identity
matching compares per token — same rule as ``node_dedupe._ip_tokens``.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
def _blank(value: Any) -> bool:
return value is None or value == ""
def _same_ieee(left: str | None, right: str | None) -> bool:
"""IEEE addresses compare case-insensitively — the same radio either way."""
if _blank(left) or _blank(right):
return False
return str(left).lower() == str(right).lower()
async def _ieee_owner(db: AsyncSession, ieee: str, *, other_than: str | None) -> InventoryDevice | None:
"""The row already holding ``ieee``, if it is not ``other_than``.
``device_inventory.ieee_address`` is UNIQUE, so writing an address a second
row owns raises. Callers ask first and leave the address where it is.
"""
stmt = select(InventoryDevice).where(func.lower(InventoryDevice.ieee_address) == ieee.lower())
if other_than is not None:
# `id != NULL` is NULL in SQL and would match nothing — only narrow when
# there is a row to exclude.
stmt = stmt.where(InventoryDevice.id != other_than)
return (await db.execute(stmt)).scalars().first()
async def _drop_taken_ieee(
db: AsyncSession, facts: Mapping[str, Any], device: InventoryDevice | None
) -> Mapping[str, Any]:
"""``facts`` without an ``ieee_address`` another inventory row already owns.
Returned unchanged in the common case. The column is UNIQUE, so writing a
duplicate raises mid-merge — and during the backfill that would cost the node
its link. The address belongs to whichever row holds it; identity for *this*
device was established by ip or mac.
"""
ieee = facts.get("ieee_address")
if _blank(ieee) or (device is not None and _same_ieee(device.ieee_address, ieee)):
return facts
owner = await _ieee_owner(db, str(ieee), other_than=device.id if device else None)
if owner is None:
return facts
logger.warning(
"IEEE %s is already held by device %s — leaving it off %s",
ieee, owner.id, device.id if device else "the new row",
)
return {k: v for k, v in facts.items() if k != "ieee_address"}
async def find_device_for(
db: AsyncSession,
*,
ip: str | None,
mac: str | None,
ieee: str | None,
) -> InventoryDevice | None:
"""Find the inventory row describing this host, or ``None``.
Precedence is ieee > ip > mac, matching ``find_duplicate_node`` and the
bulk-approve skip order so a device is identified the same way everywhere.
Hidden rows are eligible: a hidden device is still that device, and silently
minting a second row for it would resurrect the duplicate the user hid.
"""
ip_toks = _ip_tokens(ip)
conds = []
if ieee:
# Case-insensitive: `0x00124B00…` and `0x00124b00…` are the same radio,
# and an exact comparison here would mint a second row for it — one the
# UNIQUE index then refuses.
conds.append(func.lower(InventoryDevice.ieee_address) == ieee.lower())
for tok in ip_toks:
# Narrow with a substring match, then confirm per token below — an exact
# comparison misses rows holding several addresses.
conds.append(InventoryDevice.ip.contains(tok))
if mac:
conds.append(InventoryDevice.mac == mac)
if not conds:
return None
candidates = (
await db.execute(select(InventoryDevice).where(or_(*conds)).order_by(InventoryDevice.discovered_at))
).scalars().all()
for device in candidates:
if ieee and _same_ieee(device.ieee_address, ieee):
return device
for device in candidates:
# "1.2.3.4" must not match "1.2.3.40" — confirm the token, don't trust
# the SQL `contains`.
if ip_toks and set(_ip_tokens(device.ip)) & set(ip_toks):
return device
for device in candidates:
if mac and device.mac == mac:
return device
return None
def merge_properties(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
"""Union two property lists on ``key`` (case-insensitive); incoming wins.
Order-stable: existing keys keep their position, new ones are appended, so a
user's arrangement survives a merge.
"""
out: list[Any] = [dict(p) if isinstance(p, dict) else p for p in (base or [])]
index: dict[str, int] = {}
for i, prop in enumerate(out):
if isinstance(prop, dict) and prop.get("key") is not None:
index[str(prop["key"]).lower()] = i
for prop in incoming or []:
if not isinstance(prop, dict) or prop.get("key") is None:
if prop not in out:
out.append(prop)
continue
key = str(prop["key"]).lower()
pos = index.get(key)
if pos is None:
out.append(dict(prop))
index[key] = len(out) - 1
continue
current = out[pos]
if not isinstance(current, dict):
out[pos] = dict(prop)
continue
merged = {**current, **{k: v for k, v in prop.items() if not _blank(v)}}
# Keys match case-insensitively but the display spelling is the user's —
# "rack" arriving must not rewrite their "Rack".
merged["key"] = current.get("key", prop["key"])
# `visible` is a real False, not an empty value — carry it explicitly.
if "visible" in prop:
merged["visible"] = prop["visible"]
out[pos] = merged
return out
def _service_key(svc: Any) -> Any:
if not isinstance(svc, dict):
return repr(svc)
return (svc.get("port"), svc.get("protocol"), (svc.get("service_name") or "").lower())
def merge_services(base: list[Any] | None, incoming: list[Any] | None) -> list[Any]:
"""Union two service lists on (port, protocol, name); incoming wins."""
out: list[Any] = [dict(s) if isinstance(s, dict) else s for s in (base or [])]
index = {_service_key(s): i for i, s in enumerate(out)}
for svc in incoming or []:
key = _service_key(svc)
pos = index.get(key)
if pos is None:
out.append(dict(svc) if isinstance(svc, dict) else svc)
index[key] = len(out) - 1
elif isinstance(svc, dict) and isinstance(out[pos], dict):
out[pos] = {**out[pos], **svc}
else:
out[pos] = svc
return out
# Observations rather than edits: the checker and the scanner write these, so a
# client never lists them as changed and they survive a `changed_fields` filter.
_LIVE_FACT_FIELDS = frozenset({"status", "last_seen", "last_scan", "response_time_ms"})
def changed_facts(device: InventoryDevice, facts: Mapping[str, Any]) -> dict[str, Any]:
"""The subset of ``facts`` that actually differs from the row.
A canvas save sends a *full* copy of the device — the facts were hydrated
into the node when the canvas loaded — so a save triggered by nothing but a
node being dragged would otherwise rewrite the row from a snapshot that may
be minutes or hours old, silently reverting an edit made meanwhile in the
inventory modal, on another canvas, or by the scanner. Narrowing to what the
sender changed turns the write-through from "push my whole snapshot" into
"push my edit", so two writers only collide on the same field.
The comparison mirrors :func:`merge_facts_into_device`: a blank incoming
value is not a change (it never clears an established one), and a list
counts as changed only when it would actually be replaced by a different
one. Ambiguity is resolved toward reporting a change — a false positive is
the old behaviour for that field, a false negative would drop a real edit.
"""
out: dict[str, Any] = {}
for field in (*DEVICE_SCALARS, "label", "type"):
incoming = facts.get(field)
if _blank(incoming) or incoming == getattr(device, field, None):
continue
out[field] = incoming
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
out["ieee_address"] = facts["ieee_address"]
if facts.get("show_hardware") and not device.show_hardware:
out["show_hardware"] = facts["show_hardware"]
if "properties" in facts and list(facts["properties"] or []) != list(device.properties or []):
out["properties"] = facts["properties"]
if "services" in facts and list(facts["services"] or []) != list(device.services or []):
out["services"] = facts["services"]
# Live observations, not edits: carried through only where the merge would
# have used them — filling a row that has never been checked.
if facts.get("status") and device.status_live in (None, "", "unknown"):
out["status"] = facts["status"]
for field in ("last_seen", "last_scan", "response_time_ms"):
if facts.get(field) is not None:
out[field] = facts[field]
return out
def merge_facts_into_device(
device: InventoryDevice,
facts: Mapping[str, Any],
*,
overwrite_scalars: bool,
replace_lists: bool,
) -> None:
"""Fold one view of a device into its inventory row, in place.
``facts`` is a plain mapping — what a canvas save sent, or what a legacy
node's columns held — so this rule lives in one place regardless of where
the view came from.
Two independent knobs, because the callers need three combinations:
* ``overwrite_scalars`` — a non-blank incoming value replaces the row's.
True for the backfill (nodes are visited oldest-edit-first, so the most
recently edited canvas is the last writer and wins) and for a user's save.
False on approve, where the row was just discovered and the node is only a
placement. A blank *never* clears an established value in either mode.
* ``replace_lists`` — properties/services are taken wholesale rather than
unioned. True only for a user's save: otherwise a property they deleted
would come straight back on the next one. The backfill unions, so nothing
any canvas recorded is lost. It applies list by list: a list absent from
``facts`` is left alone even in replace mode, so a partial update never
clears the one it did not send.
"""
for field in (*DEVICE_SCALARS, "label", "type"):
incoming = facts.get(field)
if _blank(incoming):
continue
if overwrite_scalars or _blank(getattr(device, field, None)):
setattr(device, field, incoming)
if not _blank(facts.get("ieee_address")) and _blank(device.ieee_address):
# Identity, never overwritten — two IEEEs mean two devices.
device.ieee_address = facts["ieee_address"]
if facts.get("show_hardware") and not device.show_hardware:
device.show_hardware = True
# Per list, and only for one the caller actually sent: a partial update
# carrying properties alone must leave services as they were, not blank them.
if replace_lists and "properties" in facts:
device.properties = list(facts["properties"] or [])
else:
device.properties = merge_properties(device.properties, facts.get("properties"))
if replace_lists and "services" in facts:
device.services = list(facts["services"] or [])
else:
device.services = merge_services(device.services, facts.get("services"))
# Live status: keep the freshest observation rather than the last writer.
last_seen, status, last_scan = facts.get("last_seen"), facts.get("status"), facts.get("last_scan")
if last_seen and (device.last_seen is None or last_seen > device.last_seen):
device.last_seen = last_seen
device.status_live = status or device.status_live
device.response_time_ms = facts.get("response_time_ms")
elif device.status_live in (None, "", "unknown") and status:
device.status_live = status
if last_scan and (device.last_scan is None or last_scan > device.last_scan):
device.last_scan = last_scan
def device_from_facts(facts: Mapping[str, Any]) -> InventoryDevice:
"""Mint the inventory row for a device that has none.
Tagged ``canvas`` so the inventory filters can tell hand-drawn gear apart
from anything a scan or import found.
"""
return InventoryDevice(
label=facts.get("label"),
type=facts.get("type"),
hostname=facts.get("hostname"),
ip=facts.get("ip"),
mac=facts.get("mac"),
os=facts.get("os"),
ieee_address=facts.get("ieee_address"),
services=list(facts.get("services") or []),
properties=list(facts.get("properties") or []),
notes=facts.get("notes"),
cpu_count=facts.get("cpu_count"),
cpu_model=facts.get("cpu_model"),
ram_gb=facts.get("ram_gb"),
disk_gb=facts.get("disk_gb"),
show_hardware=bool(facts.get("show_hardware")),
check_method=facts.get("check_method"),
check_target=facts.get("check_target"),
suggested_type=facts.get("type"),
friendly_name=facts.get("label"),
# On a canvas already, so it is past the pending queue.
status="approved",
status_live=facts.get("status") or "unknown",
last_seen=facts.get("last_seen"),
last_scan=facts.get("last_scan"),
response_time_ms=facts.get("response_time_ms"),
discovery_source=CANVAS_SOURCE,
discovery_sources=[CANVAS_SOURCE],
)
async def link_facts(
db: AsyncSession,
node: Node,
facts: Mapping[str, Any],
*,
overwrite_scalars: bool = False,
replace_lists: bool = False,
only_changed: bool = False,
changed_fields: list[str] | None = None,
) -> InventoryDevice | None:
"""Point one node at its inventory row, creating or merging as needed.
``facts`` is this node's view of the device — the fields a canvas save sent,
or a legacy node's columns during the backfill. Returns the row, or ``None``
for canvas furniture. Flushes so a freshly minted row has an id to link to,
but does not commit.
Two narrowings turn a save from "push my whole snapshot" into "push my edit",
and they compose. Identity matching always uses the full ``facts`` — the row
has to be found before it can be narrowed against.
* ``changed_fields`` — what the sender says it edited since it loaded the
device. Authoritative: a fact absent from the list is not written even when
it differs, because the difference means the *row* moved on, not the sender.
* ``only_changed`` — drop facts already equal to the row (see
:func:`changed_facts`), so a no-op save writes nothing at all.
"""
if is_furniture(node.type):
node.device_id = None
return None
device = None
if node.device_id:
device = await db.get(InventoryDevice, node.device_id)
if device is None:
device = await find_device_for(
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
)
# The IEEE is UNIQUE across the inventory. Where another row already owns the
# one these facts carry, drop it rather than write a duplicate: identity has
# been resolved to *this* device by ip or mac, and the address stays with the
# row holding it.
facts = await _drop_taken_ieee(db, facts, device)
if device is None:
device = device_from_facts(facts)
db.add(device)
await db.flush()
else:
merged = dict(facts)
if changed_fields is not None:
keep = set(changed_fields) | _LIVE_FACT_FIELDS
merged = {k: v for k, v in merged.items() if k in keep}
if only_changed:
merged = changed_facts(device, merged)
merge_facts_into_device(
device,
merged,
overwrite_scalars=overwrite_scalars,
replace_lists=replace_lists,
)
device.discovery_sources = add_source(device.discovery_sources, CANVAS_SOURCE)
node.device_id = device.id
return device
def node_columns(payload: Mapping[str, Any]) -> dict[str, Any]:
"""The subset of a node payload that is still a `nodes` column.
The wire shape carries the device facts flat on the node; they belong to the
inventory row now, so they are dropped here and applied through
:func:`link_facts` instead.
"""
allowed = {c.name for c in Node.__table__.columns}
return {k: v for k, v in payload.items() if k in allowed}
# Fields that are both a node column and a device fact: the node keeps a copy so
# a half-migrated database still renders, but the row is the truth.
_SHARED_FIELDS = ("label", "type")
# Everything the inventory row owns, as it appears in a node payload.
DEVICE_FACT_FIELDS = (
*DEVICE_SCALARS, "services", "properties", "show_hardware", "status", *_SHARED_FIELDS,
)
def facts_from_update(sent: Mapping[str, Any]) -> dict[str, Any]:
"""The device facts inside a partial node update — only what was sent.
An omitted field must not clear the row, so unsent keys are simply absent.
"""
return {k: v for k, v in sent.items() if k in DEVICE_FACT_FIELDS}
def facts_from_payload(payload: Mapping[str, Any], *, label: str, node_type: str) -> dict[str, Any]:
"""The device facts inside a node save/create payload.
The wire shape still carries them flat on the node, so this is where they
are separated from the presentation fields the node itself keeps.
"""
facts: dict[str, Any] = {
field: payload.get(field)
for field in (*DEVICE_SCALARS, "services", "properties", "show_hardware", "status")
}
facts["label"] = label
facts["type"] = node_type
return facts
async def load_devices_for(db: AsyncSession, nodes: list[Node]) -> dict[str, InventoryDevice]:
"""Fetch the inventory rows a batch of nodes points at, keyed by device id."""
ids = {n.device_id for n in nodes if n.device_id}
if not ids:
return {}
rows = (
await db.execute(select(InventoryDevice).where(InventoryDevice.id.in_(ids)))
).scalars().all()
return {d.id: d for d in rows}
def hydrated_node(node: Node, device: InventoryDevice | None) -> dict[str, Any]:
"""A node as the API reports it: presentation from the node, facts from the row.
The device fields stay on the wire exactly where they have always been, so
every reader — the canvas, the live view, the MCP server — is unaffected by
the split. Canvas furniture has no row and simply reports the defaults.
"""
payload: dict[str, Any] = {
c.name: getattr(node, c.name) for c in node.__table__.columns
}
if device is None:
return payload
for field in DEVICE_SCALARS:
payload[field] = getattr(device, field, None)
payload["label"] = device.label or node.label
payload["type"] = device.type or node.type
payload["services"] = device.services or []
payload["properties"] = device.properties or []
payload["show_hardware"] = bool(device.show_hardware)
payload["ieee_address"] = device.ieee_address
payload["status"] = device.status_live or "unknown"
payload["last_seen"] = device.last_seen
payload["last_scan"] = device.last_scan
payload["response_time_ms"] = device.response_time_ms
return payload
# The device columns `nodes` carried before 3.3.0. They are read once, by the
# backfill, and then dropped — so they are named here as raw SQL rather than as
# model attributes that no longer exist.
_LEGACY_NODE_COLUMNS = (
"hostname", "ip", "mac", "os", "status", "check_method", "check_target",
"services", "notes", "cpu_count", "cpu_model", "ram_gb", "disk_gb",
"show_hardware", "properties", "ieee_address", "last_seen", "last_scan",
"response_time_ms",
)
async def _legacy_columns_present(db: AsyncSession) -> list[str]:
"""Which pre-3.3.0 device columns still exist on `nodes`."""
rows = (await db.execute(text("PRAGMA table_info(nodes)"))).all()
present = {r[1] for r in rows}
return [c for c in _LEGACY_NODE_COLUMNS if c in present]
def _decode_json(value: Any) -> Any:
"""Raw SQL hands back JSON columns as text; the ORM would have decoded them."""
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return []
return value
async def backfill_node_devices(db: AsyncSession) -> dict[str, int]:
"""Link every pre-3.3.0 canvas node to a Device Inventory row.
Non-destructive: it writes ``nodes.device_id`` and fills the inventory row,
and never deletes a node or a row. Two nodes on two canvases describing the
same host converge on one row — that convergence is the point.
Nodes are processed oldest-edit-first so that, where two canvases disagree
on a scalar, the most recently edited node is the last writer and wins;
properties and services are unioned, so nothing any canvas recorded is lost.
Reads the legacy columns with raw SQL because the model no longer declares
them — this runs on the boot that drops them, once. Returns counts for the
boot log, ``skipped`` among them: a node whose merge violates a constraint is
left unlinked instead of aborting the run. Does not commit.
"""
legacy = await _legacy_columns_present(db)
if not legacy:
# Already migrated: the columns are gone, so nothing can be left to read.
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
placeholders = ", ".join(legacy)
furniture = ", ".join(f"'{t}'" for t in sorted(FURNITURE_TYPES))
rows = (
await db.execute(
text(
f"SELECT id, label, type, design_id, {placeholders} FROM nodes "
f"WHERE device_id IS NULL AND type NOT IN ({furniture}) "
"ORDER BY updated_at, created_at, id"
)
)
).mappings().all()
if not rows:
return {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
linked = created = merged = skipped = 0
for row in rows:
facts: dict[str, Any] = {c: row[c] for c in legacy}
facts["services"] = _decode_json(facts.get("services")) or []
facts["properties"] = _decode_json(facts.get("properties")) or []
facts["label"] = row["label"]
facts["type"] = row["type"]
# One savepoint per node: a row the merge cannot write — a duplicate
# IEEE, a constraint no longer met — is dropped on its own rather than
# taking the whole backfill with it. Every node it does not link keeps
# its legacy columns, so nothing is lost and the next boot retries.
try:
async with db.begin_nested():
existing = await find_device_for(
db, ip=facts.get("ip"), mac=facts.get("mac"), ieee=facts.get("ieee_address")
)
node = await db.get(Node, row["id"])
if node is None: # pragma: no cover - the row was just read
continue
device = await link_facts(db, node, facts, overwrite_scalars=True)
if device is None:
continue
await db.flush()
except (IntegrityError, OperationalError) as exc:
skipped += 1
logger.warning(
"Inventory backfill: node %s (%s) skipped — %s", row["id"], row["label"], exc
)
continue
linked += 1
if existing is None:
created += 1
logger.info(
"Inventory backfill: node %s (%s) created device %s", node.id, row["label"], device.id
)
else:
merged += 1
logger.info(
"Inventory backfill: node %s (%s, design %s) merged into device %s",
node.id, row["label"], row["design_id"], device.id,
)
await db.flush()
return {"linked": linked, "created": created, "merged": merged, "skipped": skipped}
+55 -142
View File
@@ -1,43 +1,29 @@
"""Collapse *true* duplicate canvas nodes: same ``ieee_address`` **and** same
``design_id`` (i.e. the same device placed twice on the *same* canvas).
"""Duplicate canvas nodes: the same device drawn twice on the *same* canvas.
The same device legitimately appears on multiple canvases — placing a device on
another design creates a second :class:`Node` for that IEEE by design (see the
per-design guard in ``bulk_approve_devices``). Those cross-design rows are NOT
duplicates and must be preserved. Only two rows sharing both the IEEE *and* the
design are corrupt, and only those are collapsed here.
A device legitimately appears on several canvases — one :class:`Node` per
design, all pointing at one ``device_inventory`` row. Two nodes drawing that row
on the *same* design are the corrupt case, and the only one collapsed here.
This module provides an idempotent, **loss-free** repair: per ``(ieee,
design_id)`` group with >1 node it keeps the oldest as canonical, merges the
extras' data into it (properties, missing scalar fields, services), re-points
every edge and ``parent_id`` reference onto the canonical node, then deletes the
extras. Edges are de-duplicated and self-loops dropped after re-pointing so no
dangling or redundant links remain.
Identity is the device link, not the addresses: a node names the row it draws,
so "same device" is an id comparison rather than a guess across ieee/ip/mac.
The repair is idempotent — the oldest node stays, edges and ``parent_id``
references are re-pointed onto it, then the extras go. No device data is lost in
the process because none of it lives on the node any more.
"""
from __future__ import annotations
import logging
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Edge, Node
from app.services.zigbee_service import merge_zigbee_properties
from app.services.inventory_sync import find_device_for
logger = logging.getLogger(__name__)
def _ip_tokens(ip: str | None) -> list[str]:
"""Split a Node ``ip`` field into individual, whitespace-trimmed addresses.
The canvas stores several addresses in one comma-separated string once a
user adds e.g. an IPv6 address, so identity matching must compare per token.
"""
return [t.strip() for t in ip.split(",") if t.strip()] if ip else []
async def find_duplicate_node(
db: AsyncSession,
design_id: str | None,
@@ -45,153 +31,84 @@ async def find_duplicate_node(
mac: str | None,
ieee: str | None = None,
) -> dict[str, Any] | None:
"""Return conflict details if an equivalent node (same ieee, ip OR mac)
already sits on ``design_id``, else ``None``.
"""Return conflict details if this device is already drawn on ``design_id``.
Scoped to a single design on purpose: the same device may legitimately
appear on several canvases (one :class:`Node` per design). Only a second
node for the same ieee/ip/mac on the *same* design is a duplicate — which
the create/approve endpoints turn into a 409 so the UI can offer "go to
existing" vs "add duplicate anyway", uniformly for IEEE (Zigbee/Z-Wave) and
plain IP/ARP hosts.
Identity is resolved once, by the inventory row the addresses point at
(``find_device_for``: ieee > ip > mac), and a duplicate is simply a second
node on the same design drawing that row. Scoped to one design on purpose:
the same device may legitimately appear on several canvases.
(:func:`dedupe_nodes_by_ieee` still repairs *pre-existing* same-canvas IEEE
duplicates; this guard prevents new ones unless the user forces them.)
The create/approve endpoints turn this into a 409 so the UI can offer "go to
existing" vs "add duplicate anyway".
"""
ip_toks = _ip_tokens(ip)
conds = []
if ieee:
conds.append(Node.ieee_address == ieee)
# A node's ip may hold several comma-separated addresses (e.g. an IPv6 added
# before the IPv4), so narrow with a substring match then confirm per-token
# in Python — exact ``Node.ip == ip`` would miss those rows (issue #258).
for tok in ip_toks:
conds.append(Node.ip.contains(tok))
if mac:
conds.append(Node.mac == mac)
if not conds:
device = await find_device_for(db, ip=ip, mac=mac, ieee=ieee)
if device is None:
return None
candidates = (
existing = (
await db.execute(
select(Node).where(Node.design_id == design_id, or_(*conds))
select(Node)
.where(Node.design_id == design_id, Node.device_id == device.id)
.order_by(Node.created_at, Node.id)
)
).scalars().all()
# Confirm a real match (the ip ``contains`` above can false-positive, e.g.
# "1.2.3.4" inside "1.2.3.40"), preferring ieee > ip > mac.
def _matches(node: Node) -> tuple[str, str | None] | None:
if ieee and node.ieee_address == ieee:
return "ieee", node.ieee_address
node_toks = set(_ip_tokens(node.ip))
hit = next((t for t in ip_toks if t in node_toks), None)
if hit is not None:
return "ip", hit
if mac and node.mac == mac:
return "mac", mac
).scalars().first()
if existing is None:
return None
existing = None
matched: tuple[str, str | None] | None = None
for node in candidates:
m = _matches(node)
if m is not None:
existing, matched = node, m
break
if existing is None or matched is None:
return None
match, value = matched
# Report which address identified the device, as the UI prints it, in the
# same precedence find_device_for used: ieee > ip > mac.
match: str
value: str | None
device_ips = {t.strip() for t in (device.ip or "").split(",") if t.strip()}
shared_ip = next((t.strip() for t in (ip or "").split(",") if t.strip() in device_ips), None)
if ieee and device.ieee_address == ieee:
match, value = "ieee", ieee
elif shared_ip:
match, value = "ip", shared_ip
elif mac and device.mac == mac:
match, value = "mac", mac
else:
match, value = "ip", ip
return {
"duplicate": True,
"existing_node_id": existing.id,
"existing_label": existing.label,
"existing_label": device.label or existing.label,
"match": match,
"value": value,
}
# Scalar Node fields worth carrying over from a duplicate when the canonical
# node has no value. Positions, design, parent and identity fields are left on
# the canonical node untouched (that's the row the user actually placed).
_FILLABLE_FIELDS = (
"hostname",
"ip",
"mac",
"os",
"check_method",
"check_target",
"notes",
"cpu_count",
"cpu_model",
"ram_gb",
"disk_gb",
"custom_icon",
"last_seen",
"last_scan",
)
async def dedupe_nodes_by_device(db: AsyncSession) -> int:
"""Merge duplicate nodes drawing the same device on the same canvas.
def _merge_services(
a: list[Any] | None, b: list[Any] | None
) -> list[Any]:
"""Union two service lists, de-duplicated, order-stable."""
out: list[Any] = list(a or [])
seen = {repr(s) for s in out}
for s in b or []:
if repr(s) not in seen:
out.append(s)
seen.add(repr(s))
return out
def _merge_into_canonical(canonical: Node, dup: Node) -> None:
"""Fold ``dup``'s data into ``canonical`` in place (no field lost)."""
canonical.properties = merge_zigbee_properties(
canonical.properties, dup.properties or []
)
canonical.services = _merge_services(canonical.services, dup.services)
for field in _FILLABLE_FIELDS:
if getattr(canonical, field, None) in (None, "") and getattr(dup, field, None) not in (None, ""):
setattr(canonical, field, getattr(dup, field))
# Prefer a human label over a bare IEEE/hex fallback.
if (not canonical.label or canonical.label == canonical.ieee_address) and dup.label:
canonical.label = dup.label
async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
"""Merge duplicate nodes sharing an ``ieee_address`` AND ``design_id``.
Returns the number of nodes removed. Idempotent: a no-op when every
``(ieee, design)`` pair maps to at most one node. Nodes with the same IEEE
on *different* designs are left untouched (valid cross-canvas placement).
Returns the number of nodes removed. Idempotent. Nodes drawing one device on
*different* designs are left alone — that is valid cross-canvas placement.
The device facts live on the inventory row, so nothing has to be merged out
of the extras: only edges and parent links are re-pointed before they go.
Does not commit — the caller owns the transaction.
"""
rows = (
await db.execute(
select(Node)
.where(Node.ieee_address.is_not(None))
.order_by(Node.ieee_address, Node.created_at, Node.id)
.where(Node.device_id.is_not(None))
.order_by(Node.device_id, Node.created_at, Node.id)
)
).scalars().all()
groups: dict[tuple[str, str | None], list[Node]] = {}
for node in rows:
groups.setdefault((node.ieee_address, node.design_id), []).append(node) # type: ignore[arg-type]
groups.setdefault((node.device_id, node.design_id), []).append(node) # type: ignore[arg-type]
removed = 0
for (ieee, _design), nodes in groups.items():
for (device_id, _design), nodes in groups.items():
if len(nodes) < 2:
continue
canonical, *dups = nodes # oldest first (ordered above)
dup_ids = {d.id for d in dups}
for dup in dups:
_merge_into_canonical(canonical, dup)
# Re-point edges + parents, then drop self-loops / duplicates.
edges = (
await db.execute(
select(Edge).where(
Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids)
)
select(Edge).where(Edge.source.in_(dup_ids) | Edge.target.in_(dup_ids))
)
).scalars().all()
for edge in edges:
@@ -200,19 +117,15 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
if edge.target in dup_ids:
edge.target = canonical.id
# Re-point children whose parent was a duplicate.
children = (
await db.execute(select(Node).where(Node.parent_id.in_(dup_ids)))
).scalars().all()
for child in children:
child.parent_id = canonical.id
# Collapse self-loops and now-redundant parallel edges.
all_edges = (
await db.execute(
select(Edge).where(
(Edge.source == canonical.id) | (Edge.target == canonical.id)
)
select(Edge).where((Edge.source == canonical.id) | (Edge.target == canonical.id))
)
).scalars().all()
seen_pairs: set[tuple[str, str, str]] = set()
@@ -232,8 +145,8 @@ async def dedupe_nodes_by_ieee(db: AsyncSession) -> int:
removed += 1
logger.info(
"Deduped IEEE %s: merged %d duplicate node(s) into %s",
ieee, len(dups), canonical.id,
"Deduped device %s: merged %d duplicate node(s) into %s",
device_id, len(dups), canonical.id,
)
if removed:
+6 -17
View File
@@ -15,7 +15,7 @@ from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.models import InventoryDevice, Node, ScanRun
from app.db.models import InventoryDevice, 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
@@ -599,6 +599,10 @@ async def run_scan(
# Merged row carries both sources (e.g. ["proxmox", "arp"]).
keep.discovery_sources = add_source(keep.discovery_sources, discovery_source)
# status preserved — an approved device stays approved.
# Stamp last_scan on the row so every canvas drawing the device
# shows when the scanner last observed it. The row is the one
# place that fact belongs; a node only draws it.
keep.last_scan = datetime.now(timezone.utc)
else:
db.add(InventoryDevice(
ip=ip,
@@ -610,25 +614,10 @@ async def run_scan(
status="pending",
discovery_source=discovery_source,
discovery_sources=[discovery_source],
last_scan=datetime.now(timezone.utc),
))
devices_found += 1
# Stamp last_scan on any canvas node that matches this device by IP
# (or MAC, when known) so the inventory shows when the scanner last
# observed it. Match both the normalized and raw MAC so a legacy
# canvas node whose mac predates normalization still matches. Across
# designs.
node_match = [Node.ip == ip]
for m in {norm_mac, host.get("mac")}:
if m:
node_match.append(Node.mac == m)
matching_nodes = (await db.execute(
select(Node).where(or_(*node_match))
)).scalars().all()
scanned_at = datetime.now(timezone.utc)
for node in matching_nodes:
node.last_scan = scanned_at
await db.commit()
await broadcast_scan_update(run_id=run_id, devices_found=devices_found)
+54 -4
View File
@@ -138,6 +138,41 @@ def _service_host(svc: dict[str, Any], host: str) -> str:
return f"[{host}]" if _is_ipv6(host) else host
def _parse_override(raw: str) -> tuple[str, str | None, int | None]:
"""Split a service `host` override into (hostname, scheme, port).
Mirrors the frontend `parseHostParts`: a node can serve several domains, so
a service may carry its own host, optionally with a scheme and a port
(`blog.example.com`, `blog.example.com:8443`, `https://blog.example.com`).
"""
# Like a node ip, an override may list several hosts — the first one wins,
# same as the frontend's `splitFirstHost`.
rest = raw.split(",")[0].strip()
scheme: str | None = None
for prefix in ("https://", "http://"):
if rest.lower().startswith(prefix):
scheme = prefix[:-3]
rest = rest[len(prefix):]
break
rest = rest.split("/", 1)[0]
if rest.startswith("["):
closing = rest.find("]")
if closing == -1:
return rest, scheme, None
hostname = rest[1:closing]
remainder = rest[closing + 1:]
port = remainder[1:] if remainder.startswith(":") else ""
return hostname, scheme, int(port) if port.isdigit() else None
if rest.count(":") == 1:
hostname, _, port = rest.partition(":")
if hostname and port.isdigit():
return hostname, scheme, int(port)
return rest, scheme, None
async def check_service(svc: dict[str, Any], host: str | None) -> str:
"""Check a single service. Returns 'online' | 'offline' | 'unknown'.
@@ -146,6 +181,12 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
so it keeps its category colour instead of flashing red. An open TCP socket
doesn't prove a non-web service is healthy, so we don't pretend it does.
"""
override = str(svc.get("host") or "").strip()
override_scheme: str | None = None
override_port: int | None = None
if override:
host, override_scheme, override_port = _parse_override(override)
if not host or host.startswith("-"):
return "unknown"
if str(svc.get("protocol", "")).lower() == "udp":
@@ -153,20 +194,22 @@ async def check_service(svc: dict[str, Any], host: str | None) -> str:
port = svc.get("port")
port = int(port) if isinstance(port, int) or (isinstance(port, str) and port.isdigit()) else None
if port is None:
port = override_port
# Non-HTTP ports (SSH 22, DB, mail, …) are never checked — keep them grey.
if port is not None and port in _NON_HTTP_PORTS:
return "unknown"
name = str(svc.get("service_name", "")).lower()
is_web = port is not None or "http" in name
is_web = port is not None or "http" in name or override_scheme is not None
if not is_web:
return "unknown"
try:
scheme = "https" if (
scheme = override_scheme or ("https" if (
port in _HTTPS_PORTS or "https" in name or "ssl" in name or "tls" in name
) else "http"
) else "http")
url_host = _service_host(svc, host)
url = f"{scheme}://{url_host}" + (f":{port}" if port is not None else "")
return "online" if await _http_get(url, verify=False) else "offline"
@@ -187,6 +230,13 @@ async def check_services(
async def _one(svc: dict[str, Any]) -> dict[str, Any]:
async with sem:
status = await check_service(svc, host)
return {"port": svc.get("port"), "protocol": svc.get("protocol"), "status": status}
# `host` rides along: several vhosts can share one port on one node, so
# port+protocol alone no longer identifies a service on the client side.
return {
"port": svc.get("port"),
"protocol": svc.get("protocol"),
"host": svc.get("host"),
"status": status,
}
return await asyncio.gather(*[_one(s) for s in services]) if services else []
@@ -0,0 +1,190 @@
"""The 3.2.0 → 3.3.0 upgrade of the `nodes` table.
3.3.0 moved the device facts off `nodes` onto `device_inventory` and dropped the
columns. A real 3.2.0 database declares several of them NOT NULL with no
server-side default (`status`, `services`, `properties`, `show_hardware`) — the
schema here is `create_all`'s output at tag v3.2.0, not a hand-simplified copy,
because that constraint is exactly what breaks: the 3.3.0 model no longer writes
those columns, so if they survive the upgrade every INSERT fails with
``NOT NULL constraint failed: nodes.status`` and approving a device is dead.
"""
import os
os.environ.setdefault("SECRET_KEY", "test-only-secret-key-not-for-production")
import pytest
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import create_async_engine
import app.db.database as database
import app.services.inventory_sync as inventory_sync
# `create_all` under v3.2.0. NOT NULL is reproduced exactly where it was.
_NODES_320 = (
"CREATE TABLE nodes ("
"id VARCHAR NOT NULL PRIMARY KEY, type VARCHAR NOT NULL, label VARCHAR NOT NULL, "
"design_id VARCHAR, hostname VARCHAR, ip VARCHAR, mac VARCHAR, os VARCHAR, "
"status VARCHAR NOT NULL, check_method VARCHAR, check_target VARCHAR, "
"services JSON NOT NULL, notes TEXT, pos_x FLOAT NOT NULL, pos_y FLOAT NOT NULL, "
"parent_id VARCHAR, container_mode BOOLEAN NOT NULL, custom_colors JSON, "
"custom_icon VARCHAR, cpu_count INTEGER, cpu_model VARCHAR, ram_gb FLOAT, "
"disk_gb FLOAT, show_hardware BOOLEAN NOT NULL, show_port_numbers BOOLEAN NOT NULL, "
"properties JSON NOT NULL, width FLOAT, height FLOAT, "
"bottom_handles INTEGER NOT NULL, top_handles INTEGER NOT NULL, "
"left_handles INTEGER NOT NULL, right_handles INTEGER NOT NULL, "
"ieee_address VARCHAR, last_seen DATETIME, last_scan DATETIME, "
"response_time_ms INTEGER, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)"
)
_DESIGNS_320 = (
"CREATE TABLE designs (id VARCHAR NOT NULL PRIMARY KEY, name VARCHAR NOT NULL, "
"design_type VARCHAR NOT NULL, icon VARCHAR, created_at DATETIME NOT NULL, "
"updated_at DATETIME NOT NULL)"
)
def _node_sql(node_id: str, label: str, node_type: str, ip: str | None) -> str:
ip_sql = f"'{ip}'" if ip else "NULL"
return (
"INSERT INTO nodes (id, type, label, design_id, ip, status, services, pos_x, pos_y, "
"container_mode, show_hardware, show_port_numbers, properties, bottom_handles, "
"top_handles, left_handles, right_handles, created_at, updated_at) VALUES "
f"('{node_id}', '{node_type}', '{label}', 'd1', {ip_sql}, 'online', '[]', 0, 0, "
"0, 0, 0, '[]', 1, 1, 0, 0, '2026-01-01 00:00:00', '2026-01-01 00:00:00')"
)
@pytest.fixture
def db_320(tmp_path, monkeypatch):
"""A v3.2.0 database, with the module-global engine pointed at it."""
db_path = tmp_path / "v320.db"
monkeypatch.setattr(database.settings, "sqlite_path", str(db_path))
engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
monkeypatch.setattr(database, "engine", engine)
monkeypatch.setattr(
database, "AsyncSessionLocal", database.async_sessionmaker(engine, expire_on_commit=False)
)
return db_path, engine
async def _build_320(engine) -> None:
async with engine.begin() as conn:
await conn.exec_driver_sql(_DESIGNS_320)
await conn.exec_driver_sql(_NODES_320)
await conn.exec_driver_sql(
"INSERT INTO designs (id, name, design_type, icon, created_at, updated_at) "
"VALUES ('d1', 'Network Topology', 'network', 'dashboard', "
"'2026-01-01 00:00:00', '2026-01-01 00:00:00')"
)
await conn.exec_driver_sql(_node_sql("n1", "OpnSense", "firewall", "192.168.1.1"))
await conn.exec_driver_sql(_node_sql("n2", "NAS", "nas", "192.168.1.2"))
await conn.exec_driver_sql(_node_sql("n3", "a note", "text", None))
async def _insert_node(engine) -> None:
"""What approve does: insert a node the way the 3.3.0 model writes one."""
from app.db.models import Node
session_factory = database.async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
session.add(Node(id="new", type="firewall", label="Approved", design_id="d1"))
await session.commit()
async def test_upgrade_drops_the_device_columns_and_leaves_nodes_insertable(db_320):
db_path, engine = db_320
await _build_320(engine)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
assert "status" not in cols
assert "ip" not in cols
assert "device_id" in cols
# Every device node linked; the facts moved to the inventory row.
linked = (await conn.exec_driver_sql(
"SELECT label, device_id FROM nodes ORDER BY id"
)).fetchall()
assert [row[0] for row in linked] == ["OpnSense", "NAS", "a note"]
assert linked[0][1] and linked[1][1]
assert linked[2][1] is None # canvas furniture keeps no row
ips = (await conn.exec_driver_sql(
"SELECT ip FROM device_inventory ORDER BY ip"
)).fetchall()
assert [row[0] for row in ips] == ["192.168.1.1", "192.168.1.2"]
await _insert_node(engine)
finally:
await check.dispose()
await engine.dispose()
async def test_a_node_the_backfill_cannot_link_still_leaves_nodes_insertable(db_320, monkeypatch):
"""The regression: a failed backfill must not brick every later INSERT.
Before the fix, one unlinkable node kept `nodes.status` NOT NULL while the
3.3.0 model stopped writing it, so approving *any* device raised
``NOT NULL constraint failed: nodes.status`` (issue #351).
"""
db_path, engine = db_320
await _build_320(engine)
real_link_facts = inventory_sync.link_facts
async def link_facts(db, node, facts, **kwargs):
if node.id == "n2":
raise IntegrityError("boom", None, Exception("UNIQUE constraint failed"))
return await real_link_facts(db, node, facts, **kwargs)
monkeypatch.setattr(inventory_sync, "link_facts", link_facts)
await database.init_db()
check = create_async_engine(f"sqlite+aiosqlite:///{db_path}")
try:
async with check.begin() as conn:
info = (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()
cols = {c[1]: c for c in info}
# The columns stay — they are the only remaining copy of n2's facts.
assert "status" in cols
# But nothing is NOT NULL any more, so the model can insert again.
assert not any(c[3] for name, c in cols.items() if name in database._LEGACY_NODE_COLUMNS)
# Values preserved by the rebuild.
kept = (await conn.exec_driver_sql(
"SELECT status, ip FROM nodes WHERE id='n2'"
)).fetchone()
assert kept == ("online", "192.168.1.2")
# The node that did link is linked; the failed one is not.
linked = dict((await conn.exec_driver_sql(
"SELECT id, device_id FROM nodes"
)).fetchall())
assert linked["n1"] is not None
assert linked["n2"] is None
await _insert_node(engine)
# A boot where the failure persists changes nothing further: the relax is
# a one-off, not a rebuild on every start.
await database.init_db()
async with check.begin() as conn:
kept = (await conn.exec_driver_sql(
"SELECT status, ip FROM nodes WHERE id='n2'"
)).fetchone()
assert kept == ("online", "192.168.1.2")
assert (await conn.exec_driver_sql(
"SELECT COUNT(*) FROM nodes"
)).scalar() == 4 # the three seeded, plus the one just approved
# A later boot retries the backfill: with the failure gone, n2 links and
# the columns finally drop.
monkeypatch.setattr(inventory_sync, "link_facts", real_link_facts)
await database.init_db()
async with check.begin() as conn:
cols = {c[1] for c in (await conn.exec_driver_sql("PRAGMA table_info(nodes)")).fetchall()}
assert "status" not in cols
finally:
await check.dispose()
await engine.dispose()
+37 -6
View File
@@ -11,23 +11,54 @@ async def _add_design(db_session, name: str) -> str:
return design.id
def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node:
def _node(design_id: str, *, ip=None, ieee=None, mac=None, device_id=None) -> Node:
"""A canvas node. Addresses belong to the inventory row it draws, so pass
`device_id`, or let `_node_for` mint the row from the addresses."""
return Node(
id=str(uuid.uuid4()), label="n", type="server", status="online",
ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
design_id=design_id,
id=str(uuid.uuid4()), label="n", type="server", pos_x=0.0, pos_y=0.0,
design_id=design_id, device_id=device_id,
)
async def _node_for(db_session, design_id: str, *, ip=None, ieee=None, mac=None, **device_kwargs) -> Node:
"""A node plus the inventory row carrying its addresses, linked.
Reuses a row that already describes these addresses — one device is one row,
so a test placing the same host on a second canvas gets a second node, not a
second device.
"""
from app.services.inventory_sync import find_device_for
device = await find_device_for(db_session, ip=ip, mac=mac, ieee=ieee)
if device is None:
device = InventoryDevice(
id=str(uuid.uuid4()), ip=ip, mac=mac, ieee_address=ieee,
status="approved", status_live="online", **device_kwargs,
)
db_session.add(device)
await db_session.flush()
node = _node(design_id, device_id=device.id)
db_session.add(node)
await db_session.commit()
return node
async def _seed_zigbee_pending_pair(db_session):
"""Create a coordinator Node + a pending device + a link between them."""
from app.db.models import InventoryDeviceLink, Node
coord_device = InventoryDevice(
ieee_address="0xCOORD",
friendly_name="Coordinator",
suggested_type="zigbee_coordinator",
status="approved",
)
db_session.add(coord_device)
await db_session.flush()
coord = Node(
label="Coordinator",
type="zigbee_coordinator",
status="unknown",
ieee_address="0xCOORD",
device_id=coord_device.id,
)
db_session.add(coord)
+62 -47
View File
@@ -6,13 +6,13 @@ from httpx import AsyncClient
from sqlalchemy import select
from app.db.models import Design, InventoryDevice, Node
from tests.scan.helpers import _add_design, _node
from tests.scan.helpers import _add_design, _node_for
@pytest.mark.asyncio
async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device):
# A node with no design_id is not "on a canvas".
db_session.add(_node(None, ip="192.168.1.100"))
await _node_for(db_session, None, ip="192.168.1.100")
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
@@ -57,16 +57,17 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
design = Design(name="d1")
db_session.add(design)
await db_session.flush()
existing = Node(
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
services=[], design_id=design.id,
)
db_session.add(existing)
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
db_session.add(device)
await db_session.flush()
# The node already on this design draws that very row.
existing = Node(
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
)
db_session.add(existing)
await db_session.commit()
res = await client.post(
@@ -86,7 +87,7 @@ async def test_approve_device_conflicts_on_existing_ieee_same_design(
# No second node created; device stays pending until the user decides.
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
).scalars().all()
assert len(nodes) == 1
@@ -99,15 +100,15 @@ async def test_approve_device_force_creates_duplicate_ieee(
design = Design(name="d1")
db_session.add(design)
await db_session.flush()
db_session.add(Node(
label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ",
services=[], design_id=design.id,
))
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice",
status="pending", discovery_source="zigbee",
)
db_session.add(device)
await db_session.flush()
db_session.add(Node(
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=design.id,
))
await db_session.commit()
res = await client.post(
@@ -120,7 +121,7 @@ async def test_approve_device_force_creates_duplicate_ieee(
)
assert res.status_code == 200
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ"))
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0xZZZ"))
).scalars().all()
assert len(nodes) == 2
@@ -133,8 +134,7 @@ async def test_approve_device_conflicts_on_existing_ip(
silently duplicated: the approve returns 409 with the existing node so the
UI can ask the user."""
design = await _add_design(db_session, "Home")
existing = _node(design, ip="192.168.1.100")
db_session.add(existing)
existing = await _node_for(db_session, design, ip="192.168.1.100")
await db_session.commit()
res = await client.post(
@@ -163,10 +163,8 @@ async def test_approve_device_conflicts_on_existing_mac(
):
"""MAC match (device re-IP'd via DHCP) also triggers the duplicate guard."""
design = await _add_design(db_session, "Home")
existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online",
ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design)
db_session.add(existing)
await db_session.commit()
# The node already on this design draws a row carrying that MAC.
await _node_for(db_session, design, ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff")
# pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip.
res = await client.post(
@@ -188,8 +186,7 @@ async def test_approve_device_conflicts_on_ip_in_comma_list(
as. Exact-string matching missed it (issue #258); per-token matching catches
the duplicate."""
design = await _add_design(db_session, "Home")
existing = _node(design, ip="fe80::1, 192.168.1.100")
db_session.add(existing)
existing = await _node_for(db_session, design, ip="fe80::1, 192.168.1.100")
await db_session.commit()
res = await client.post(
@@ -212,7 +209,7 @@ async def test_approve_device_no_conflict_on_ip_substring(
"""The ip guard must match whole addresses, not substrings: a node at
10.0.0.40 is not a duplicate of a device at 10.0.0.4."""
design = await _add_design(db_session, "Home")
db_session.add(_node(design, ip="10.0.0.40"))
await _node_for(db_session, design, ip="10.0.0.40")
await db_session.commit()
res = await client.post(
@@ -230,7 +227,7 @@ async def test_approve_device_force_creates_duplicate(
):
"""force=True (user confirmed) bypasses the guard and creates the node."""
design = await _add_design(db_session, "Home")
db_session.add(_node(design, ip="192.168.1.100"))
await _node_for(db_session, design, ip="192.168.1.100")
await db_session.commit()
res = await client.post(
@@ -251,7 +248,7 @@ async def test_approve_device_allows_same_ip_on_other_design(
"""The guard is per-design: the same host on a different canvas is fine."""
other = await _add_design(db_session, "Lab")
target = await _add_design(db_session, "Home")
db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design
await _node_for(db_session, other, ip="192.168.1.100") # exists on a DIFFERENT design
await db_session.commit()
res = await client.post(
@@ -273,13 +270,16 @@ async def test_approve_device_places_already_approved_on_another_design(
other = await _add_design(db_session, "Other")
target = await _add_design(db_session, "Network Topology")
# Device is on `other` already (its global status is "approved").
db_session.add(_node(other, ieee="0x00158d0005292b83"))
device = InventoryDevice(
id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83",
suggested_type="zigbee_enddevice", status="approved",
discovery_source="zigbee",
)
db_session.add(device)
await db_session.flush()
db_session.add(Node(
label="sensor", type="zigbee_enddevice", device_id=device.id, design_id=other,
))
await db_session.commit()
res = await client.post(
@@ -292,7 +292,7 @@ async def test_approve_device_places_already_approved_on_another_design(
# A node now exists on the target design too (one per canvas).
nodes = (
await db_session.execute(
select(Node).where(Node.ieee_address == "0x00158d0005292b83")
select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "0x00158d0005292b83")
)
).scalars().all()
assert {n.design_id for n in nodes} == {other, target}
@@ -464,7 +464,7 @@ async def test_bulk_approve_skips_device_already_on_target_design(
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
# First device already sits on the canvas (matched by ip).
db_session.add(_node(design, ip="192.168.1.10"))
await _node_for(db_session, design, ip="192.168.1.10")
await db_session.commit()
res = await client.post(
@@ -480,9 +480,10 @@ async def test_bulk_approve_skips_device_already_on_target_design(
nodes = (
await db_session.execute(select(NodeModel).where(NodeModel.design_id == design))
).scalars().all()
devices = [await db_session.get(InventoryDevice, n.device_id) for n in nodes]
# The pre-existing node plus the one newly approved — no duplicate for .10.
assert len(nodes) == 2
assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"]
assert sorted(d.ip for d in devices) == ["192.168.1.10", "192.168.1.11"]
@pytest.mark.asyncio
@@ -493,7 +494,7 @@ async def test_bulk_approve_skips_device_matching_ip_in_comma_list(
as the plain IPv4 is still recognised as already placed (issue #258)."""
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
db_session.add(_node(design, ip="fe80::1, 192.168.1.10"))
await _node_for(db_session, design, ip="fe80::1, 192.168.1.10")
await db_session.commit()
res = await client.post(
@@ -515,8 +516,7 @@ async def test_bulk_approve_reports_skipped_devices(
(with the existing node id) instead of silently dropping it."""
ids = [d.id for d in two_device_inventory]
design = await _add_design(db_session, "Canvas")
existing = _node(design, ip="192.168.1.10")
db_session.add(existing)
existing = await _node_for(db_session, design, ip="192.168.1.10")
await db_session.commit()
res = await client.post(
@@ -551,10 +551,14 @@ async def test_approve_device_copies_mac_to_node_and_properties(
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
assert node.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
).scalars().first()
assert node is not None
# The device facts belong to the inventory row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.mac == "aa:bb:cc:dd:ee:ff"
mac_props = [p for p in device.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False}
]
@@ -587,10 +591,14 @@ async def test_bulk_approve_copies_mac_to_node_and_properties(
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55"))
).scalar_one()
assert node.mac == "11:22:33:44:55:66"
mac_props = [p for p in node.properties if p["key"] == "MAC"]
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
).scalars().first()
assert node is not None
# The device facts belong to the inventory row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.mac == "11:22:33:44:55:66"
mac_props = [p for p in device.properties if p["key"] == "MAC"]
assert mac_props == [
{"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False}
]
@@ -607,8 +615,10 @@ async def test_bulk_approve_sets_default_check_method(client: AsyncClient, heade
assert res.status_code == 200
nodes = (await db_session.execute(select(NodeModel))).scalars().all()
for n in nodes:
if n.ip:
assert n.check_method == "ping", f"node {n.id} created without check_method"
device = await db_session.get(InventoryDevice, n.device_id)
assert device is not None
if device.ip:
assert device.check_method == "ping", f"device {device.id} has no check_method"
@pytest.mark.asyncio
@@ -624,7 +634,9 @@ async def test_approve_device_sets_default_check_method(client: AsyncClient, hea
assert res.status_code == 200
node = (await db_session.execute(select(NodeModel))).scalars().first()
assert node is not None
assert node.check_method == "ping"
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.check_method == "ping"
@pytest.mark.asyncio
@@ -713,11 +725,14 @@ async def test_bulk_approve_targets_requested_design(client, headers, db_session
assert res.json()["approved"] == 1
node = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2"))
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-2"))
).scalar_one()
assert node.design_id == active
assert node.design_id != first
# Z-Wave device → online + Z-Wave property rows, no ICMP check.
assert node.status == "online"
assert node.check_method == "none"
assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"}
# Z-Wave device → online + Z-Wave property rows, no ICMP check. All of that
# is the device's, so it is read off the row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.status_live == "online"
assert device.check_method == "none"
assert {p["key"] for p in device.properties} == {"Z-Wave ID", "Vendor", "Model"}
@@ -0,0 +1,165 @@
"""Editing an inventory row: PATCH /scan/pending/{id} and the widened create.
The inventory row owns the device facts, so it has to be editable on its own —
not only through a canvas node.
"""
import pytest
from httpx import AsyncClient
from app.db.models import InventoryDevice
async def _seed(db_session, **kwargs) -> InventoryDevice:
device = InventoryDevice(
ip="192.168.1.10",
mac="aa:bb:cc:11:22:33",
hostname="nas",
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
suggested_type="nas",
status="pending",
discovery_source="arp",
discovery_sources=["arp"],
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
**kwargs,
)
db_session.add(device)
await db_session.commit()
return device
@pytest.mark.asyncio
async def test_update_pending_requires_auth(client: AsyncClient, db_session):
device = await _seed(db_session)
res = await client.patch(f"/api/v1/scan/pending/{device.id}", json={"hostname": "x"})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_update_pending_edits_curated_fields(client: AsyncClient, headers, db_session):
device = await _seed(db_session)
res = await client.patch(
f"/api/v1/scan/pending/{device.id}",
headers=headers,
json={
"label": "Big NAS",
"type": "nas",
"notes": "in the garage rack",
"cpu_count": 4,
"cpu_model": "N5105",
"ram_gb": 16.0,
"disk_gb": 4000.0,
"show_hardware": True,
"check_method": "http",
"check_target": "http://192.168.1.10:5000",
"properties": [
{"key": "Rack", "value": "B2", "icon": None, "visible": True},
{"key": "Owner", "value": "me", "icon": None, "visible": False},
],
"services": [
{"port": 22, "protocol": "tcp", "service_name": "ssh"},
{"port": 5000, "protocol": "tcp", "service_name": "http"},
],
},
)
assert res.status_code == 200
data = res.json()
assert data["label"] == "Big NAS"
assert data["notes"] == "in the garage rack"
assert data["cpu_count"] == 4
assert data["ram_gb"] == 16.0
assert data["show_hardware"] is True
assert data["check_method"] == "http"
assert len(data["properties"]) == 2
assert len(data["services"]) == 2
@pytest.mark.asyncio
async def test_update_pending_is_partial(client: AsyncClient, headers, db_session):
"""Sending one field must not clear the others."""
device = await _seed(db_session)
res = await client.patch(
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"notes": "only this"}
)
assert res.status_code == 200
data = res.json()
assert data["notes"] == "only this"
assert data["hostname"] == "nas"
assert data["ip"] == "192.168.1.10"
assert data["mac"] == "aa:bb:cc:11:22:33"
assert data["suggested_type"] == "nas"
assert len(data["services"]) == 1
assert data["properties"][0]["key"] == "Rack"
@pytest.mark.asyncio
async def test_update_pending_normalizes_mac(client: AsyncClient, headers, db_session):
device = await _seed(db_session)
res = await client.patch(
f"/api/v1/scan/pending/{device.id}", headers=headers, json={"mac": "AA-BB-CC-99-88-77"}
)
assert res.status_code == 200
assert res.json()["mac"] == "aa:bb:cc:99:88:77"
@pytest.mark.asyncio
async def test_update_pending_leaves_lifecycle_alone(client: AsyncClient, headers, db_session):
"""`status` is owned by approve/hide, not by the edit modal."""
device = await _seed(db_session)
res = await client.patch(
f"/api/v1/scan/pending/{device.id}",
headers=headers,
json={"label": "x", "status": "approved", "discovery_source": "manual"},
)
assert res.status_code == 200
data = res.json()
assert data["label"] == "x"
assert data["status"] == "pending"
assert data["discovery_source"] == "arp"
@pytest.mark.asyncio
async def test_update_pending_unknown_id(client: AsyncClient, headers):
res = await client.patch("/api/v1/scan/pending/nope", headers=headers, json={"notes": "x"})
assert res.status_code == 404
@pytest.mark.asyncio
async def test_create_pending_accepts_curated_fields(client: AsyncClient, headers):
"""A hand-made entry carries everything the modal shows, no PATCH round trip."""
res = await client.post(
"/api/v1/scan/pending",
headers=headers,
json={
"hostname": "patch-panel",
"os": "n/a",
"label": "Patch panel 24p",
"type": "switch",
"notes": "passive",
"services": [{"port": 80, "protocol": "tcp", "service_name": "http"}],
"friendly_name": "PP-1",
"device_subtype": "passive",
"check_method": "none",
"discovery_source": "manual",
},
)
assert res.status_code == 201
data = res.json()
assert data["label"] == "Patch panel 24p"
assert data["type"] == "switch"
assert data["os"] == "n/a"
assert data["notes"] == "passive"
assert data["friendly_name"] == "PP-1"
assert data["services"][0]["port"] == 80
assert data["status"] == "pending"
@pytest.mark.asyncio
async def test_legacy_row_defaults_are_coerced(client: AsyncClient, headers, db_session):
"""Rows predating the 3.3.0 columns read back with defaults, not nulls."""
device = await _seed(db_session)
res = await client.get("/api/v1/scan/pending", headers=headers)
assert res.status_code == 200
row = next(d for d in res.json() if d["id"] == device.id)
assert row["status_live"] == "unknown"
assert row["show_hardware"] is False
assert row["label"] is None
+30 -14
View File
@@ -31,9 +31,13 @@ async def test_approve_zigbee_device_populates_properties(
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
).scalars().first()
assert node is not None
# The device facts belong to the inventory row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
keys = {p["key"]: p["value"] for p in device.properties}
assert keys == {
"IEEE": "0xABCDEF",
"Vendor": "IKEA",
@@ -56,14 +60,18 @@ async def test_bulk_approve_zigbee_populates_properties(
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF"))
).scalar_one()
keys = {p["key"]: p["value"] for p in node.properties}
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
).scalars().first()
assert node is not None
# The device facts belong to the inventory row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
keys = {p["key"]: p["value"] for p in device.properties}
assert keys["IEEE"] == "0xABCDEF"
assert keys["Vendor"] == "IKEA"
assert keys["Model"] == "TRADFRI"
assert keys["LQI"] == "180"
assert node.check_method == "none"
assert device.check_method == "none"
def test_build_mac_property_returns_hidden_row():
@@ -133,9 +141,13 @@ async def test_approve_device_does_not_duplicate_mac_property(
)
assert res.status_code == 200
node = (
await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100"))
).scalar_one()
mac_props = [p for p in node.properties if p["key"] == "MAC"]
await db_session.execute(select(NodeModel).where(NodeModel.device_id.is_not(None)))
).scalars().first()
assert node is not None
# The device facts belong to the inventory row the node draws.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
mac_props = [p for p in device.properties if p["key"] == "MAC"]
assert len(mac_props) == 1
# User's visibility choice is preserved.
assert mac_props[0]["visible"] is True
@@ -328,9 +340,13 @@ async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_ses
assert res.status_code == 200
node = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9"))
await db_session.execute(select(Node).join(InventoryDevice, Node.device_id == InventoryDevice.id).where(InventoryDevice.ieee_address == "zwave-H-9"))
).scalar_one()
assert node.design_id == active
assert node.status == "online"
assert node.check_method == "none"
assert any(p["key"] == "Z-Wave ID" for p in node.properties)
node = (await db_session.execute(select(Node))).scalars().first()
assert node is not None
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.status_live == "online"
assert device.check_method == "none"
assert any(p["key"] == "Z-Wave ID" for p in device.properties)
+62 -16
View File
@@ -7,7 +7,7 @@ import pytest
from httpx import AsyncClient
from app.db.models import InventoryDevice
from tests.scan.helpers import _add_design, _node
from tests.scan.helpers import _add_design, _node_for
@pytest.mark.asyncio
@@ -55,7 +55,7 @@ async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session
# Node.ip holds several comma-separated addresses (IPv6 added first). The
# device scanned as the plain IPv4 must still correlate (issue #258).
d1 = await _add_design(db_session, "Home")
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
await _node_for(db_session, d1, ip="fe80::1, 192.168.1.100")
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
@@ -67,7 +67,7 @@ async def test_canvas_count_correlates_by_mac(client, headers, db_session, pendi
# Node's ip differs entirely (user edited it) but the MAC still matches:
# the device is on the canvas (issue #258, MAC is the stable identifier).
d1 = await _add_design(db_session, "Home")
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
await _node_for(db_session, d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff")
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
@@ -79,8 +79,8 @@ async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_se
# Same IP placed on two different canvases → canvas_count == 2.
d1 = await _add_design(db_session, "Home")
d2 = await _add_design(db_session, "Lab")
db_session.add(_node(d1, ip="192.168.1.100"))
db_session.add(_node(d2, ip="192.168.1.100"))
await _node_for(db_session, d1, ip="192.168.1.100")
await _node_for(db_session, d2, ip="192.168.1.100")
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
@@ -97,7 +97,7 @@ async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
)
db_session.add(device)
d1 = await _add_design(db_session, "Zigbee")
db_session.add(_node(d1, ieee="0x00124b001"))
await _node_for(db_session, d1, ieee="0x00124b001")
await db_session.commit()
res = await client.get("/api/v1/scan/pending", headers=headers)
@@ -120,10 +120,13 @@ async def test_pending_device_without_node_has_null_node_timestamps(client, head
@pytest.mark.asyncio
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
d1 = await _add_design(db_session, "Home")
node = _node(d1, ip="192.168.1.100")
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
db_session.add(node)
node = await _node_for(db_session, d1, ip="192.168.1.100")
# When the scanner last saw the device, and when it last answered, are facts
# about the device — they live on its row, not on any one drawing of it.
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
device.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
device.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
@@ -139,18 +142,21 @@ async def test_node_timestamps_aggregate_across_matches(client, headers, db_sess
# last_scan takes the NEWEST.
d1 = await _add_design(db_session, "Home")
d2 = await _add_design(db_session, "Lab")
older = _node(d1, ip="192.168.1.100")
older = await _node_for(db_session, d1, ip="192.168.1.100")
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
newer = _node(d2, ip="192.168.1.100")
newer = await _node_for(db_session, d2, ip="192.168.1.100")
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
# Both nodes draw the one row, which carries the scan observation.
assert older.device_id == newer.device_id
device = await db_session.get(InventoryDevice, older.device_id)
assert device is not None
device.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
db_session.add_all([older, newer])
await db_session.commit()
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
assert data["node_created_at"].startswith("2026-01-01") # oldest
assert data["node_last_scan"].startswith("2026-06-01") # newest
assert data["node_created_at"].startswith("2026-01-01") # oldest node
assert data["node_last_scan"].startswith("2026-06-01") # off the device row
@pytest.mark.asyncio
@@ -291,6 +297,46 @@ async def test_create_pending_keeps_a_missing_mac_null(client: AsyncClient, head
assert res.json()["mac"] is None
@pytest.mark.asyncio
async def test_create_pending_unhides_the_row_it_merges_into(client: AsyncClient, headers, db_session):
"""Adding a device by hand outranks an earlier hide of the same host.
One device is one row, hidden ones included — but merging into a hidden row
and leaving it hidden answers 201 while nothing appears in the inventory, so
the add reads as a no-op.
"""
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.50", status="hidden"))
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending",
json={"ip": "192.168.1.50", "hostname": "printer", "discovery_source": "manual"},
headers=headers,
)
assert res.status_code == 201, res.text
assert res.json()["id"] == "d-1"
assert res.json()["status"] == "pending"
listed = (await client.get("/api/v1/scan/pending", headers=headers)).json()
assert [d["id"] for d in listed] == ["d-1"]
@pytest.mark.asyncio
async def test_create_pending_leaves_an_approved_row_approved(client: AsyncClient, headers, db_session):
"""Only `hidden` is lifted — a merge must not walk a device back down the
lifecycle and re-queue something already on a canvas."""
db_session.add(InventoryDevice(id="d-1", ip="192.168.1.51", status="approved"))
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending",
json={"ip": "192.168.1.51", "hostname": "nas", "discovery_source": "manual"},
headers=headers,
)
assert res.status_code == 201, res.text
assert res.json()["status"] == "approved"
@pytest.mark.asyncio
async def test_delete_pending_removes_one_entry(client: AsyncClient, headers, pending_device):
# The rack canvas drops the placeholder it created for a plate once that
+32 -25
View File
@@ -131,16 +131,6 @@ async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
in the inventory and are surfaced with an "In N canvas" badge."""
node = Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
pos_x=0.0,
pos_y=0.0,
)
stale = InventoryDevice(
id=str(uuid.uuid4()),
ip="192.168.1.50",
@@ -151,8 +141,17 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
suggested_type="generic",
status="pending",
)
db_session.add(node)
db_session.add(stale)
await db_session.flush()
# A canvas already draws that device.
db_session.add(Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
device_id=stale.id,
pos_x=0.0,
pos_y=0.0,
))
await db_session.commit()
run_id = str(uuid.uuid4())
@@ -174,19 +173,23 @@ async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSe
@pytest.mark.asyncio
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
"""A scanned IP that already exists as a canvas Node still produces a pending
device (no longer suppressed)."""
node = Node(
"""A scanned IP already drawn on a canvas refreshes the one inventory row.
The device is never suppressed, and never duplicated: the node draws that
row, so a second row for the same host would split the device in two.
"""
existing = InventoryDevice(id=str(uuid.uuid4()), ip="192.168.1.50", status="approved")
db_session.add(existing)
await db_session.flush()
existing_id = existing.id
db_session.add(Node(
id=str(uuid.uuid4()),
label="Existing Server",
type="server",
ip="192.168.1.50",
status="online",
services=[],
device_id=existing.id,
pos_x=0.0,
pos_y=0.0,
)
db_session.add(node)
))
await db_session.commit()
run_id = str(uuid.uuid4())
@@ -200,12 +203,16 @@ async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
):
await run_scan(["192.168.1.0/24"], db_session, run_id)
result = await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
device = result.scalar_one_or_none()
assert device is not None
assert device.status == "pending"
rows = (
await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ip == "192.168.1.50")
)
).scalars().all()
assert len(rows) == 1
assert rows[0].id == existing_id
# The scan refreshed it; it did not reset the lifecycle.
assert rows[0].status == "approved"
assert rows[0].hostname == MOCK_HOST["hostname"]
@pytest.mark.asyncio
+35
View File
@@ -124,6 +124,41 @@ def test_suggest_node_type_priority_proxmox_over_server():
assert result == "proxmox"
# A port-agnostic signature: only an HTTP probe can ever match it.
KVM_SIGNATURE = {
"port": None, "protocol": "tcp", "banner_regex": None, "http_regex": "PiKVM",
"service_name": "PiKVM", "icon": "monitorcog", "category": "kvm",
"suggested_node_type": "kvm",
}
def test_suggest_node_type_kvm_from_http_title():
# An IP-KVM answers on 443 like any web app; only the probed title identifies it.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
])
assert result == "kvm"
def test_suggest_node_type_priority_kvm_over_server():
# The same host also answers on 80 (→ server); the KVM signal outranks it.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([
{"port": 80, "protocol": "tcp"},
{"port": 443, "protocol": "tcp", "http_signals": {"title": "PiKVM", "headers": {}}},
])
assert result == "kvm"
def test_suggest_node_type_ignores_port_agnostic_sig_without_probe():
# No http_signals (probe disabled) → port-agnostic entries can't match and the
# port-only guess stands, unchanged from pre-probe behaviour.
with patch("app.services.fingerprint._load", return_value=[*MOCK_SIGNATURES, KVM_SIGNATURE]):
result = suggest_node_type([{"port": 443, "protocol": "tcp"}])
assert result == "server"
def test_suggest_node_type_camera_from_rtsp_port():
# RTSP port 554 → camera (via _PORT_TYPE_HINTS)
result = suggest_node_type([{"port": 554, "protocol": "tcp"}])
+855
View File
@@ -0,0 +1,855 @@
"""The node ↔ Device Inventory link: matching, merging, and the 3.3.0 backfill.
The inventory row owns the device facts; a node owns how the device is drawn.
These tests pin the rules that make one device end up as one row even when it
was drawn on several canvases.
"""
import json
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from httpx import AsyncClient
from sqlalchemy import select, text
from app.db.models import Design, InventoryDevice, Node
from app.services.inventory_sync import (
backfill_node_devices,
changed_facts,
find_device_for,
link_facts,
merge_properties,
merge_services,
)
def _now(offset_minutes: int = 0) -> datetime:
return datetime(2026, 1, 1, 12, 0, tzinfo=timezone.utc) + timedelta(minutes=offset_minutes)
async def _design(db_session, name="Net") -> 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, **kwargs) -> Node:
payload = {
"id": str(uuid.uuid4()),
"label": "n",
"type": "server",
"pos_x": 0.0,
"pos_y": 0.0,
"design_id": design_id,
}
payload.update(kwargs)
return Node(**payload)
# --- pure merge rules -------------------------------------------------------
class TestMergeRules:
def test_properties_union_on_key_with_incoming_winning(self):
base = [{"key": "Rack", "value": "A1", "icon": None, "visible": True}]
incoming = [
{"key": "rack", "value": "B2", "icon": None, "visible": False},
{"key": "Owner", "value": "me", "icon": None, "visible": True},
]
out = merge_properties(base, incoming)
assert [p["value"] for p in out] == ["B2", "me"]
# Existing keys keep their position — a user's ordering survives.
assert out[0]["key"] == "Rack"
assert out[0]["visible"] is False
def test_properties_keep_established_value_when_incoming_is_blank(self):
out = merge_properties(
[{"key": "Rack", "value": "A1", "icon": "Server", "visible": True}],
[{"key": "Rack", "value": "", "icon": None, "visible": True}],
)
assert out[0]["value"] == "A1"
assert out[0]["icon"] == "Server"
def test_services_union_on_port_protocol_and_name(self):
base = [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
incoming = [
{"port": 22, "protocol": "tcp", "service_name": "SSH", "icon": "Terminal"},
{"port": 80, "protocol": "tcp", "service_name": "http"},
]
out = merge_services(base, incoming)
assert len(out) == 2
assert out[0]["icon"] == "Terminal"
assert out[1]["port"] == 80
class TestChangedFacts:
"""What a save is allowed to write: its edit, not its whole snapshot."""
def test_an_unchanged_snapshot_writes_nothing(self):
device = InventoryDevice(
id="d-1",
label="NAS",
type="nas",
hostname="nas.lan",
ip="10.0.0.5",
notes="in the garage",
status_live="online",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
)
facts = {
"label": "NAS",
"type": "nas",
"hostname": "nas.lan",
"ip": "10.0.0.5",
"notes": "in the garage",
"status": "online",
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
}
assert changed_facts(device, facts) == {}
def test_only_the_edited_field_survives(self):
device = InventoryDevice(id="d-1", hostname="nas.lan", ip="10.0.0.5", notes="old")
facts = {"hostname": "nas.lan", "ip": "10.0.0.5", "notes": "new"}
assert changed_facts(device, facts) == {"notes": "new"}
def test_a_blank_incoming_value_is_not_a_change(self):
# A blank never clears an established value, so it is not an edit either.
device = InventoryDevice(id="d-1", hostname="nas.lan", notes="keep me")
assert changed_facts(device, {"hostname": "", "notes": None}) == {}
def test_a_changed_list_is_sent_whole(self):
device = InventoryDevice(
id="d-1",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
)
facts = {
"properties": [],
"services": [{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
}
# Replace semantics need the full list, and the untouched one stays out.
assert changed_facts(device, facts) == {"properties": []}
def test_live_status_only_fills_a_row_never_checked(self):
unknown = InventoryDevice(id="d-1", status_live="unknown")
assert changed_facts(unknown, {"status": "online"}) == {"status": "online"}
checked = InventoryDevice(id="d-2", status_live="offline")
# The status checker owns reachability; a stale canvas must not reset it.
assert changed_facts(checked, {"status": "online"}) == {}
# --- matching ---------------------------------------------------------------
class TestFindDeviceFor:
@pytest.mark.asyncio
async def test_prefers_ieee_over_ip_and_mac(self, db_session):
by_ip = InventoryDevice(id="d-ip", ip="10.0.0.5")
by_ieee = InventoryDevice(id="d-ieee", ieee_address="0xAAA")
db_session.add_all([by_ip, by_ieee])
await db_session.commit()
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee="0xAAA")
assert found is not None and found.id == "d-ieee"
@pytest.mark.asyncio
async def test_matches_one_address_out_of_a_comma_list(self, db_session):
device = InventoryDevice(id="d-1", ip="10.0.0.5, fd00::1")
db_session.add(device)
await db_session.commit()
found = await find_device_for(db_session, ip="fd00::1", mac=None, ieee=None)
assert found is not None and found.id == "d-1"
@pytest.mark.asyncio
async def test_does_not_match_a_longer_address_with_the_same_prefix(self, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.40"))
await db_session.commit()
assert await find_device_for(db_session, ip="10.0.0.4", mac=None, ieee=None) is None
@pytest.mark.asyncio
async def test_matches_a_hidden_row_rather_than_minting_a_second(self, db_session):
db_session.add(InventoryDevice(id="d-hidden", ip="10.0.0.5", status="hidden"))
await db_session.commit()
found = await find_device_for(db_session, ip="10.0.0.5", mac=None, ieee=None)
assert found is not None and found.id == "d-hidden"
@pytest.mark.asyncio
async def test_matches_an_ieee_written_in_another_case(self, db_session):
"""One radio, one row: `ieee_address` is UNIQUE, so a case-sensitive
match here would mint a second row the index then refuses."""
db_session.add(InventoryDevice(id="d-1", ieee_address="0x00124b0022334455"))
await db_session.commit()
found = await find_device_for(
db_session, ip=None, mac=None, ieee="0x00124B0022334455"
)
assert found is not None and found.id == "d-1"
class TestIeeeCollisions:
"""`device_inventory.ieee_address` is UNIQUE — a merge must never duplicate it."""
@pytest.mark.asyncio
async def test_an_address_another_row_owns_is_left_where_it_is(self, db_session):
"""The node is already linked, so identity is settled — the IEEE is not
a reason to move it, and writing it here would violate the index."""
design = await _design(db_session)
db_session.add_all([
InventoryDevice(id="d-radio", ieee_address="0xAAA"),
InventoryDevice(id="d-host", ip="10.0.0.5"),
])
node = _node(design, device_id="d-host")
db_session.add(node)
await db_session.commit()
device = await link_facts(
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
)
await db_session.commit()
assert device is not None and device.id == "d-host"
assert device.ieee_address is None
radio = await db_session.get(InventoryDevice, "d-radio")
assert radio is not None and radio.ieee_address == "0xAAA"
@pytest.mark.asyncio
async def test_the_row_that_already_holds_it_keeps_writing_it(self, db_session):
design = await _design(db_session)
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", ieee_address="0xAAA"))
node = _node(design)
db_session.add(node)
await db_session.commit()
device = await link_facts(
db_session, node, {"ip": "10.0.0.5", "ieee_address": "0xAAA"}, overwrite_scalars=True
)
await db_session.commit()
assert device is not None and device.ieee_address == "0xAAA"
# --- the backfill -----------------------------------------------------------
class TestBackfill:
"""The 3.3.0 migration path.
A pre-3.3.0 database still has the device columns on `nodes`; the backfill
reads them with raw SQL (the model no longer declares them) and folds each
node's view into an inventory row. These tests recreate that shape.
"""
async def _legacy_nodes_table(self, db_session) -> None:
"""Re-add the pre-3.3.0 device columns to `nodes`."""
for column, sql_type in (
("hostname", "VARCHAR"), ("ip", "VARCHAR"), ("mac", "VARCHAR"), ("os", "VARCHAR"),
("status", "VARCHAR"), ("check_method", "VARCHAR"), ("check_target", "VARCHAR"),
("services", "JSON"), ("notes", "TEXT"), ("cpu_count", "INTEGER"),
("cpu_model", "VARCHAR"), ("ram_gb", "FLOAT"), ("disk_gb", "FLOAT"),
("show_hardware", "BOOLEAN"), ("properties", "JSON"), ("ieee_address", "VARCHAR"),
("last_seen", "DATETIME"), ("last_scan", "DATETIME"), ("response_time_ms", "INTEGER"),
):
await db_session.execute(text(f"ALTER TABLE nodes ADD COLUMN {column} {sql_type}"))
await db_session.commit()
async def _legacy_node(self, db_session, design_id: str, *, updated_at=None, **facts) -> str:
node_id = str(uuid.uuid4())
columns = {
"id": node_id,
"label": facts.pop("label", "n"),
"type": facts.pop("type", "server"),
"design_id": design_id,
"pos_x": 0.0,
"pos_y": 0.0,
"container_mode": 0,
"show_port_numbers": 0,
"bottom_handles": 1,
"top_handles": 1,
"left_handles": 0,
"right_handles": 0,
"created_at": _now(0),
"updated_at": updated_at or _now(0),
}
for key in ("services", "properties"):
if key in facts:
facts[key] = json.dumps(facts[key])
columns.update(facts)
names = ", ".join(columns)
binds = ", ".join(f":{c}" for c in columns)
await db_session.execute(text(f"INSERT INTO nodes ({names}) VALUES ({binds})"), columns)
await db_session.commit()
return node_id
@pytest.mark.asyncio
async def test_does_nothing_when_the_columns_are_already_gone(self, db_session):
"""A second boot: there is no legacy data left to read."""
design = await _design(db_session)
db_session.add(_node(design))
await db_session.commit()
assert await backfill_node_devices(db_session) == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
@pytest.mark.asyncio
async def test_links_a_node_to_its_existing_row(self, db_session):
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
await db_session.commit()
node_id = await self._legacy_node(db_session, design, ip="10.0.0.5", label="NAS")
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats == {"linked": 1, "created": 0, "merged": 1, "skipped": 0}
node = await db_session.get(Node, node_id)
assert node is not None and node.device_id == "d-1"
@pytest.mark.asyncio
async def test_creates_a_row_for_a_node_no_scan_ever_saw(self, db_session):
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
node_id = await self._legacy_node(
db_session, design, label="Dumb switch", type="switch", notes="under the desk"
)
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats == {"linked": 1, "created": 1, "merged": 0, "skipped": 0}
node = await db_session.get(Node, node_id)
assert node is not None
device = await db_session.get(InventoryDevice, node.device_id)
assert device is not None
assert device.label == "Dumb switch"
assert device.type == "switch"
assert device.notes == "under the desk"
assert device.status == "approved"
assert device.discovery_sources == ["canvas"]
@pytest.mark.asyncio
async def test_two_canvases_one_device_merge_into_one_row(self, db_session):
"""The whole point: the same host drawn twice becomes one inventory row."""
await self._legacy_nodes_table(db_session)
design_a = await _design(db_session, "A")
design_b = await _design(db_session, "B")
older = await self._legacy_node(
db_session, design_a,
ip="10.0.0.5", label="nas-old", hostname="nas.lan", notes="older note",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
updated_at=_now(0),
)
newer = await self._legacy_node(
db_session, design_b,
ip="10.0.0.5", label="nas-new", os="TrueNAS",
properties=[{"key": "Owner", "value": "me", "icon": None, "visible": True}],
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
updated_at=_now(30),
)
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 2
node_a = await db_session.get(Node, older)
node_b = await db_session.get(Node, newer)
assert node_a is not None and node_b is not None
assert node_a.device_id == node_b.device_id
device = await db_session.get(InventoryDevice, node_a.device_id)
assert device is not None
# Scalars: most recent edit wins, blanks never wipe an established value.
assert device.label == "nas-new"
assert device.hostname == "nas.lan"
assert device.os == "TrueNAS"
assert device.notes == "older note"
# Cumulative fields: union of both canvases.
assert {p["key"] for p in device.properties} == {"Rack", "Owner"}
assert {s["port"] for s in device.services} == {22, 80}
@pytest.mark.asyncio
async def test_leaves_canvas_furniture_alone(self, db_session):
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
for kind in ("group", "groupRect", "text"):
await self._legacy_node(db_session, design, type=kind, label=kind)
stats = await backfill_node_devices(db_session)
await db_session.commit()
assert stats["linked"] == 0
assert (await db_session.execute(select(InventoryDevice))).scalars().all() == []
@pytest.mark.asyncio
async def test_is_a_no_op_on_a_second_run(self, db_session):
await self._legacy_nodes_table(db_session)
design = await _design(db_session)
await self._legacy_node(db_session, design, ip="10.0.0.5")
first = await backfill_node_devices(db_session)
await db_session.commit()
second = await backfill_node_devices(db_session)
await db_session.commit()
assert first["linked"] == 1
assert second == {"linked": 0, "created": 0, "merged": 0, "skipped": 0}
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert len(rows) == 1
# --- routes -----------------------------------------------------------------
class TestRoutesKeepTheLinkInStep:
@pytest.mark.asyncio
async def test_creating_a_node_joins_the_matching_row(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", os="TrueNAS"))
await db_session.commit()
res = await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
assert res.status_code == 201
body = res.json()
assert body["device_id"] == "d-1"
# Hydrated from the row: the node never carried an OS.
assert body["os"] == "TrueNAS"
@pytest.mark.asyncio
async def test_creating_furniture_creates_no_row(self, client: AsyncClient, headers, db_session):
res = await client.post(
"/api/v1/nodes", json={"type": "groupRect", "label": "Zone"}, headers=headers
)
assert res.status_code == 201
assert res.json()["device_id"] is None
rows = (await db_session.execute(select(InventoryDevice))).scalars().all()
assert rows == []
@pytest.mark.asyncio
async def test_editing_a_node_writes_through_to_the_row(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas"))
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
res = await client.patch(
f"/api/v1/nodes/{node_id}",
json={"hostname": "nas2.lan", "notes": "moved to the garage"},
headers=headers,
)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.hostname == "nas2.lan"
assert device.notes == "moved to the garage"
@pytest.mark.asyncio
async def test_patching_one_list_leaves_the_other_alone(self, client: AsyncClient, headers, db_session):
"""Replace applies list by list — an unsent list is not blanked."""
db_session.add(
InventoryDevice(
id="d-1",
ip="10.0.0.5",
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
)
)
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
# properties only: services must survive untouched.
res = await client.patch(
f"/api/v1/nodes/{node_id}",
json={"properties": [{"key": "Owner", "value": "me", "icon": None, "visible": True}]},
headers=headers,
)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert [p["key"] for p in device.properties] == ["Owner"]
assert [s["port"] for s in device.services] == [22]
# services only: the properties just written must survive in turn.
res = await client.patch(
f"/api/v1/nodes/{node_id}",
json={"services": [{"port": 443, "protocol": "tcp", "service_name": "https"}]},
headers=headers,
)
assert res.status_code == 200
await db_session.refresh(device)
assert [s["port"] for s in device.services] == [443]
assert [p["key"] for p in device.properties] == ["Owner"]
@pytest.mark.asyncio
async def test_patching_a_list_empty_still_clears_it(self, client: AsyncClient, headers, db_session):
"""An explicit empty list is a deletion, not an omission."""
db_session.add(
InventoryDevice(
id="d-1",
ip="10.0.0.5",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
)
)
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
res = await client.patch(f"/api/v1/nodes/{node_id}", json={"properties": []}, headers=headers)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.properties == []
@pytest.mark.asyncio
async def test_a_canvas_save_does_not_revert_an_inventory_edit(
self, client: AsyncClient, headers, db_session
):
"""Regression: a save carries this canvas' edit, not its stale snapshot.
The canvas payload holds a full copy of the device, hydrated when it
loaded. Editing the device elsewhere and then saving the still-open
canvas — for nothing but a moved node — must not roll the row back.
"""
db_session.add(
InventoryDevice(
id="d-1",
ip="10.0.0.5",
label="NAS",
type="nas",
notes="old note",
status="approved",
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
)
)
await db_session.commit()
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
# What the canvas loaded and still holds in memory.
stale = {
"id": str(uuid.uuid4()),
"type": "nas",
"label": "NAS",
"status": "unknown",
"pos_x": 0,
"pos_y": 0,
"ip": "10.0.0.5",
"notes": "old note",
"properties": [{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
}
res = await client.post(
"/api/v1/canvas/save",
json={"design_id": design_id, "nodes": [stale], "edges": [], "viewport": {}},
headers=headers,
)
assert res.status_code == 200
# Meanwhile, in the Device Inventory modal.
res = await client.patch(
"/api/v1/scan/pending/d-1",
json={
"notes": "moved to the loft",
"properties": [
{"key": "Rack", "value": "A1", "icon": None, "visible": True},
{"key": "Owner", "value": "me", "icon": None, "visible": True},
],
},
headers=headers,
)
assert res.status_code == 200
# The canvas saves again — only the node position changed, so it claims
# no device edit at all.
res = await client.post(
"/api/v1/canvas/save",
json={
"design_id": design_id,
"nodes": [{**stale, "pos_x": 240, "changed_facts": []}],
"edges": [],
"viewport": {},
},
headers=headers,
)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.notes == "moved to the loft"
assert [p["key"] for p in device.properties] == ["Rack", "Owner"]
@pytest.mark.asyncio
async def test_a_canvas_save_still_writes_what_that_canvas_changed(
self, client: AsyncClient, headers, db_session
):
"""The narrowing must not cost the write-through: an edit still lands."""
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note"))
await db_session.commit()
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
node = {
"id": str(uuid.uuid4()),
"type": "nas",
"label": "NAS",
"status": "unknown",
"pos_x": 0,
"pos_y": 0,
"ip": "10.0.0.5",
"notes": "old note",
}
await client.post(
"/api/v1/canvas/save",
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
headers=headers,
)
res = await client.post(
"/api/v1/canvas/save",
json={
"design_id": design_id,
"nodes": [
{
**node,
"notes": "edited on the canvas",
"hostname": "nas.lan",
"changed_facts": ["notes", "hostname"],
}
],
"edges": [],
"viewport": {},
},
headers=headers,
)
assert res.status_code == 200
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.notes == "edited on the canvas"
assert device.hostname == "nas.lan"
@pytest.mark.asyncio
async def test_an_edit_writes_without_dragging_the_rest_of_the_snapshot(
self, client: AsyncClient, headers, db_session
):
"""One edited fact lands; the stale fields beside it stay out of the write."""
db_session.add(
InventoryDevice(id="d-1", ip="10.0.0.5", label="NAS", type="nas", notes="old note")
)
await db_session.commit()
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
node = {
"id": str(uuid.uuid4()),
"type": "nas",
"label": "NAS",
"status": "unknown",
"pos_x": 0,
"pos_y": 0,
"ip": "10.0.0.5",
"notes": "old note",
}
await client.post(
"/api/v1/canvas/save",
json={"design_id": design_id, "nodes": [node], "edges": [], "viewport": {}},
headers=headers,
)
await client.patch("/api/v1/scan/pending/d-1", json={"notes": "moved to the loft"}, headers=headers)
# The canvas renames the node; its `notes` copy is stale but unedited.
await client.post(
"/api/v1/canvas/save",
json={
"design_id": design_id,
"nodes": [{**node, "label": "Big NAS", "changed_facts": ["label"]}],
"edges": [],
"viewport": {},
},
headers=headers,
)
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.label == "Big NAS"
assert device.notes == "moved to the loft"
@pytest.mark.asyncio
async def test_a_client_that_tracks_no_changes_still_writes_its_facts(
self, client: AsyncClient, headers, db_session
):
"""Backward compatible: an import or older client omits the field entirely."""
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5", notes="old note"))
await db_session.commit()
design_id = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
await client.post(
"/api/v1/canvas/save",
json={
"design_id": design_id,
"nodes": [
{
"id": str(uuid.uuid4()),
"type": "nas",
"label": "NAS",
"status": "unknown",
"pos_x": 0,
"pos_y": 0,
"ip": "10.0.0.5",
"notes": "from the import",
}
],
"edges": [],
"viewport": {},
},
headers=headers,
)
device = await db_session.get(InventoryDevice, "d-1")
await db_session.refresh(device)
assert device.notes == "from the import"
@pytest.mark.asyncio
async def test_the_second_canvas_sees_the_first_canvas_edit(self, client: AsyncClient, headers, db_session):
"""No per-node overrides: one device, one set of facts, every canvas."""
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
await db_session.commit()
design_a = (await client.post("/api/v1/designs", json={"name": "A"}, headers=headers)).json()["id"]
design_b = (await client.post("/api/v1/designs", json={"name": "B"}, headers=headers)).json()["id"]
node_a = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_a, "force": True},
headers=headers,
)
).json()["id"]
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "design_id": design_b, "force": True},
headers=headers,
)
await client.patch(f"/api/v1/nodes/{node_a}", json={"ip": "10.0.0.9"}, headers=headers)
canvas_b = (await client.get(f"/api/v1/canvas?design_id={design_b}", headers=headers)).json()
assert canvas_b["nodes"][0]["ip"] == "10.0.0.9"
@pytest.mark.asyncio
async def test_the_wire_shape_the_mcp_server_reads_is_unchanged(
self, client: AsyncClient, headers, db_session
):
"""`mcp/app/tools.py` reads the facts flat on the node — keep it that way.
The MCP server is a thin proxy over these routes: `_slim_canvas` keeps
`NODE_KEEP`, `_slim_node_summary` keeps label/type/status, and `get_node`
looks a node up by label. None of it knows the inventory row exists, so
the split must not show on the wire.
"""
db_session.add(
InventoryDevice(
id="d-1",
ip="10.0.0.5",
hostname="nas.lan",
mac="aa:bb:cc:dd:ee:ff",
os="TrueNAS",
notes="in the garage",
cpu_count=8,
cpu_model="Xeon",
ram_gb=32.0,
disk_gb=4000.0,
services=[{"port": 22, "protocol": "tcp", "service_name": "ssh"}],
properties=[{"key": "Rack", "value": "A1", "icon": None, "visible": True}],
status_live="online",
)
)
await db_session.commit()
design_id = (await client.post("/api/v1/designs", json={"name": "Net"}, headers=headers)).json()["id"]
created = (
await client.post(
"/api/v1/nodes",
json={
"type": "nas", "label": "NAS", "ip": "10.0.0.5",
"design_id": design_id, "force": True,
},
headers=headers,
)
).json()
# mcp `_slim_canvas` NODE_KEEP, plus what `_slim_node_summary` reads.
expected = {
"type": "nas",
"label": "NAS",
"ip": "10.0.0.5",
"hostname": "nas.lan",
"mac": "aa:bb:cc:dd:ee:ff",
"os": "TrueNAS",
"status": "online",
"notes": "in the garage",
"cpu_count": 8,
"cpu_model": "Xeon",
"ram_gb": 32.0,
"disk_gb": 4000.0,
}
for source in (
created,
(await client.get(f"/api/v1/nodes/{created['id']}", headers=headers)).json(),
(await client.get("/api/v1/nodes", headers=headers)).json()[0],
(await client.get("/api/v1/canvas", headers=headers)).json()["nodes"][0],
# mcp `get_node` falls back to a label lookup when given no id.
(await client.get("/api/v1/nodes?label=NAS", headers=headers)).json()[0],
):
assert {k: source[k] for k in expected} == expected
assert source["services"] == [{"port": 22, "protocol": "tcp", "service_name": "ssh"}]
assert source["properties"] == [
{"key": "Rack", "value": "A1", "icon": None, "visible": True}
]
assert "parent_id" in source
@pytest.mark.asyncio
async def test_deleting_a_node_keeps_the_device(self, client: AsyncClient, headers, db_session):
db_session.add(InventoryDevice(id="d-1", ip="10.0.0.5"))
await db_session.commit()
node_id = (
await client.post(
"/api/v1/nodes",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "force": True},
headers=headers,
)
).json()["id"]
assert (await client.delete(f"/api/v1/nodes/{node_id}", headers=headers)).status_code == 204
assert await db_session.get(InventoryDevice, "d-1") is not None
@pytest.mark.asyncio
async def test_approving_a_device_links_instead_of_copying(self, client: AsyncClient, headers, db_session):
db_session.add(
InventoryDevice(id="d-1", ip="10.0.0.5", hostname="nas", suggested_type="nas", status="pending")
)
await db_session.commit()
res = await client.post(
"/api/v1/scan/pending/d-1/approve",
json={"type": "nas", "label": "NAS", "ip": "10.0.0.5", "status": "unknown"},
headers=headers,
)
assert res.status_code == 200
node = await db_session.get(Node, res.json()["node_id"])
assert node is not None and node.device_id == "d-1"
+56 -29
View File
@@ -1,10 +1,16 @@
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe)."""
"""Tests for the same-canvas node dedupe repair (app.services.node_dedupe).
Identity is the device link now: two nodes are duplicates when they draw the
same `device_inventory` row on the same design. The device facts live on that
row, so nothing has to be merged out of the extras before they go — only edges
and parent links are re-pointed.
"""
import pytest
from sqlalchemy import select
from app.db.models import Design, Edge, Node
from app.services.node_dedupe import dedupe_nodes_by_ieee
from app.db.models import Design, Edge, InventoryDevice, Node
from app.services.node_dedupe import dedupe_nodes_by_device
async def _design(db, name="d1"):
@@ -14,61 +20,80 @@ async def _design(db, name="d1"):
return d
async def _device(db, ieee="0xAAA", **kwargs):
device = InventoryDevice(ieee_address=ieee, status="approved", **kwargs)
db.add(device)
await db.flush()
return device
@pytest.mark.asyncio
async def test_collapses_same_ieee_same_design(db_session):
async def test_collapses_same_device_same_design(db_session):
d = await _design(db_session)
device = await _device(db_session)
keep = Node(
label="Sensor", type="zigbee_enddevice", design_id=d.id,
ieee_address="0xAAA", properties=[{"key": "IEEE", "value": "0xAAA", "visible": True}],
pos_x=100, pos_y=200,
device_id=device.id, pos_x=100, pos_y=200,
)
db_session.add(keep)
await db_session.flush()
dup = Node(
label="Sensor", type="zigbee_enddevice", design_id=d.id,
ieee_address="0xAAA", ip="10.0.0.5",
properties=[{"key": "LQI", "value": "88", "visible": False}],
)
dup = Node(label="Sensor", type="zigbee_enddevice", design_id=d.id, device_id=device.id)
db_session.add(dup)
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
removed = await dedupe_nodes_by_device(db_session)
assert removed == 1
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xAAA"))).scalars().all()
nodes = (
await db_session.execute(select(Node).where(Node.device_id == device.id))
).scalars().all()
assert len(nodes) == 1
survivor = nodes[0]
assert survivor.id == keep.id # oldest kept
assert survivor.pos_x == 100 # canvas position preserved
assert survivor.ip == "10.0.0.5" # missing field filled from dup
keys = {p["key"] for p in survivor.properties}
assert keys == {"IEEE", "LQI"} # properties merged
# The device facts were never on the node, so none can be lost here.
assert await db_session.get(InventoryDevice, device.id) is not None
@pytest.mark.asyncio
async def test_preserves_same_ieee_across_designs(db_session):
async def test_preserves_same_device_across_designs(db_session):
"""Same device on two canvases is valid — must NOT be merged."""
d1 = await _design(db_session, "d1")
d2 = await _design(db_session, "d2")
device = await _device(db_session, ieee="0xBBB")
for d in (d1, d2):
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, ieee_address="0xBBB"))
db_session.add(Node(label="S", type="zigbee_enddevice", design_id=d.id, device_id=device.id))
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
removed = await dedupe_nodes_by_device(db_session)
assert removed == 0
nodes = (await db_session.execute(select(Node).where(Node.ieee_address == "0xBBB"))).scalars().all()
nodes = (
await db_session.execute(select(Node).where(Node.device_id == device.id))
).scalars().all()
assert len(nodes) == 2
@pytest.mark.asyncio
async def test_leaves_furniture_alone(db_session):
"""Zones and text carry no device link, so they are never duplicates."""
d = await _design(db_session)
for _ in range(2):
db_session.add(Node(label="Zone", type="groupRect", design_id=d.id))
await db_session.flush()
assert await dedupe_nodes_by_device(db_session) == 0
@pytest.mark.asyncio
async def test_repoints_edges_and_drops_dupes(db_session):
d = await _design(db_session)
keep = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
dup = Node(label="A", type="server", design_id=d.id, ieee_address="0xCCC")
device = await _device(db_session, ieee="0xCCC")
keep = Node(label="A", type="server", design_id=d.id, device_id=device.id)
other = Node(label="B", type="server", design_id=d.id)
db_session.add_all([keep, other])
await db_session.flush()
dup = Node(label="A", type="server", design_id=d.id, device_id=device.id)
db_session.add(dup)
await db_session.flush()
@@ -80,7 +105,7 @@ async def test_repoints_edges_and_drops_dupes(db_session):
])
await db_session.flush()
removed = await dedupe_nodes_by_ieee(db_session)
removed = await dedupe_nodes_by_device(db_session)
assert removed == 1
edges = (await db_session.execute(select(Edge))).scalars().all()
@@ -93,17 +118,18 @@ async def test_repoints_edges_and_drops_dupes(db_session):
@pytest.mark.asyncio
async def test_repoints_child_parent(db_session):
d = await _design(db_session)
keep = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
dup = Node(label="Host", type="proxmox", design_id=d.id, ieee_address="0xDDD")
device = await _device(db_session, ieee="0xDDD")
keep = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
db_session.add(keep)
await db_session.flush()
dup = Node(label="Host", type="proxmox", design_id=d.id, device_id=device.id)
db_session.add(dup)
await db_session.flush()
child = Node(label="VM", type="vm", design_id=d.id, parent_id=dup.id)
db_session.add(child)
await db_session.flush()
await dedupe_nodes_by_ieee(db_session)
await dedupe_nodes_by_device(db_session)
await db_session.refresh(child)
assert child.parent_id == keep.id
@@ -111,7 +137,8 @@ async def test_repoints_child_parent(db_session):
@pytest.mark.asyncio
async def test_idempotent_noop_when_unique(db_session):
d = await _design(db_session)
db_session.add(Node(label="X", type="server", design_id=d.id, ieee_address="0xEEE"))
device = await _device(db_session, ieee="0xEEE")
db_session.add(Node(label="X", type="server", design_id=d.id, device_id=device.id))
await db_session.flush()
assert await dedupe_nodes_by_ieee(db_session) == 0
assert await dedupe_nodes_by_ieee(db_session) == 0
assert await dedupe_nodes_by_device(db_session) == 0
assert await dedupe_nodes_by_device(db_session) == 0
+47 -25
View File
@@ -222,25 +222,33 @@ async def test_persist_creates_pending(db_session) -> None:
@pytest.mark.asyncio
async def test_persist_merges_existing_scanned_node_by_ip(db_session) -> None:
# A device previously found by an IP scan (no ieee, no specs).
# The scanned host: one inventory row, drawn by one node.
scanned_device = InventoryDevice(
id=str(uuid.uuid4()), ip="10.0.0.5", suggested_type="generic",
status="approved", discovery_source="arp", discovery_sources=["arp"],
)
db_session.add(scanned_device)
await db_session.flush()
scanned = Node(
id=str(uuid.uuid4()), type="generic", label="10.0.0.5",
ip="10.0.0.5", status="online", pos_x=0, pos_y=0,
device_id=scanned_device.id, pos_x=0, pos_y=0,
)
db_session.add(scanned)
await db_session.commit()
await _persist_pending_import(db_session, [_guest_node(101, "10.0.0.5")], [])
# No duplicate node; identity + specs merged onto the existing one.
nodes = (await db_session.execute(select(Node).where(Node.ip == "10.0.0.5"))).scalars().all()
# No duplicate node, and no second row: the import merged into the row the
# node already draws.
nodes = (await db_session.execute(select(Node))).scalars().all()
assert len(nodes) == 1
merged = nodes[0]
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
assert merged is not None
assert merged.ieee_address == "pve-pve1-101"
assert merged.cpu_count == 2
assert any(p["key"] == "CPU Cores" for p in (merged.properties or []))
# Inventory row exists as approved (already on canvas).
inv = (await db_session.execute(select(InventoryDevice).where(InventoryDevice.ieee_address == "pve-pve1-101"))).scalar_one()
assert inv.status == "approved"
# Still approved — it is on a canvas.
assert merged.status == "approved"
@pytest.mark.asyncio
@@ -290,17 +298,17 @@ async def test_persist_preserves_ip_tag_for_legacy_null_source_row(db_session) -
async def test_persist_preserves_ip_tag_on_canvas_merge_legacy_row(db_session) -> None:
# The immich case: an on-canvas node from an old scan, with a legacy
# inventory row (NULL source). Merge must keep the IP tag on the row.
node = Node(
id=str(uuid.uuid4()), type="lxc", label="immich",
ip="192.168.1.108", mac="bc:24:11:6c:96:52",
status="online", pos_x=0, pos_y=0,
)
inv = InventoryDevice(
id=str(uuid.uuid4()), ip="192.168.1.108", mac="bc:24:11:6c:96:52",
suggested_type="lxc", status="approved",
discovery_source=None, discovery_sources=[],
)
db_session.add_all([node, inv])
db_session.add(inv)
await db_session.flush()
db_session.add(Node(
id=str(uuid.uuid4()), type="lxc", label="immich",
device_id=inv.id, pos_x=0, pos_y=0,
))
await db_session.commit()
await _persist_pending_import(
@@ -328,9 +336,15 @@ async def test_persist_does_not_add_ip_tag_to_pure_proxmox_guest(db_session) ->
@pytest.mark.asyncio
async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
# A scanned canvas node with a MAC but no IP recorded for the guest.
scanned_device = InventoryDevice(
id=str(uuid.uuid4()), mac="bc:24:11:aa:bb:cc", suggested_type="generic",
status="approved", discovery_source="arp", discovery_sources=["arp"],
)
db_session.add(scanned_device)
await db_session.flush()
scanned = Node(
id=str(uuid.uuid4()), type="generic", label="box",
mac="bc:24:11:aa:bb:cc", status="online", pos_x=0, pos_y=0,
device_id=scanned_device.id, pos_x=0, pos_y=0,
)
db_session.add(scanned)
await db_session.commit()
@@ -339,7 +353,8 @@ async def test_persist_merges_canvas_node_by_mac(db_session) -> None:
nodes = (await db_session.execute(select(Node))).scalars().all()
assert len(nodes) == 1 # no duplicate node
merged = nodes[0]
merged = await db_session.get(InventoryDevice, nodes[0].device_id)
assert merged is not None
assert merged.ieee_address == "pve-pve1-101"
assert merged.mac == "bc:24:11:aa:bb:cc"
assert merged.cpu_count == 2 # specs backfilled
@@ -420,12 +435,14 @@ async def test_cluster_link_resolves_to_cluster_edge(db_session) -> None:
# Two host nodes already on a canvas + a pending cluster link between them.
design = Design(id=str(uuid.uuid4()), name="d")
db_session.add(design)
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", ieee_address="pve-node-a",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", ieee_address="pve-node-b",
status="online", pos_x=0, pos_y=0, design_id=design.id,
left_handles=1, right_handles=1)
dev_a = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-a", status="approved")
dev_b = InventoryDevice(id=str(uuid.uuid4()), ieee_address="pve-node-b", status="approved")
db_session.add_all([dev_a, dev_b])
await db_session.flush()
a = Node(id=str(uuid.uuid4()), type="proxmox", label="a", device_id=dev_a.id,
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
b = Node(id=str(uuid.uuid4()), type="proxmox", label="b", device_id=dev_b.id,
pos_x=0, pos_y=0, design_id=design.id, left_handles=1, right_handles=1)
db_session.add_all([a, b])
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="pve-node-a", target_ieee="pve-node-b",
@@ -451,13 +468,18 @@ async def test_link_survives_and_resolves_onto_second_design(db_session) -> None
da = Design(id=str(uuid.uuid4()), name="a")
db_ = Design(id=str(uuid.uuid4()), name="b")
db_session.add_all([da, db_])
# One row per device, whatever the canvas count.
dev_x = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xAAAA", status="approved")
dev_y = InventoryDevice(id=str(uuid.uuid4()), ieee_address="0xBBBB", status="approved")
db_session.add_all([dev_x, dev_y])
await db_session.flush()
# Same two devices placed on BOTH designs (one Node per canvas).
for d in (da, db_):
db_session.add_all([
Node(id=str(uuid.uuid4()), type="iot", label="x", ieee_address="0xAAAA",
status="online", pos_x=0, pos_y=0, design_id=d.id),
Node(id=str(uuid.uuid4()), type="iot", label="y", ieee_address="0xBBBB",
status="online", pos_x=0, pos_y=0, design_id=d.id),
Node(id=str(uuid.uuid4()), type="iot", label="x", device_id=dev_x.id,
pos_x=0, pos_y=0, design_id=d.id),
Node(id=str(uuid.uuid4()), type="iot", label="y", device_id=dev_y.id,
pos_x=0, pos_y=0, design_id=d.id),
])
db_session.add(InventoryDeviceLink(
id=str(uuid.uuid4()), source_ieee="0xAAAA", target_ieee="0xBBBB",
+32 -26
View File
@@ -468,6 +468,21 @@ class TestInventory:
"""The rack prints the logical view's facts, so the endpoint must ship them."""
rack_design = await _design(client, headers)
network = await _design(client, headers, name="Network", design_type="network")
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does. Seeded before the node so the node links to this
# row rather than minting a second one for the same host.
db_session.add(
InventoryDevice(
id="pd-nas",
hostname="nas",
ip="192.168.1.9",
mac="aa:bb:cc:dd:ee:ff",
os="TrueNAS",
suggested_type="nas",
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
)
)
await db_session.commit()
await client.post(
"/api/v1/nodes",
json={
@@ -483,20 +498,6 @@ class TestInventory:
},
headers=headers,
)
# Straight to the table: the manual-create schema carries no os/services,
# and a scan find does.
db_session.add(
InventoryDevice(
id="pd-nas",
hostname="nas",
ip="192.168.1.9",
mac="aa:bb:cc:dd:ee:ff",
os="TrueNAS",
suggested_type="nas",
services=[{"port": 445, "service_name": "smb", "category": "storage"}],
)
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={rack_design}", headers=headers)
@@ -507,8 +508,10 @@ class TestInventory:
assert entry["services"] == [{"port": 445, "name": "smb"}]
assert entry["node_label"] == "nas-truenas"
assert entry["node_type"] == "nas"
assert entry["node_hostname"] == "nas.lan"
assert entry["node_os"] == "TrueNAS SCALE"
# The node no longer keeps its own copy: both views read the row, which
# the node create filled in without overwriting what the scan found.
assert entry["node_hostname"] == "nas"
assert entry["node_os"] == "TrueNAS"
assert entry["node_check_method"] == "http"
assert entry["node_design_id"] == network
assert entry["node_design_name"] == "Network"
@@ -525,17 +528,16 @@ class TestInventory:
headers=headers,
)
).json()
db_session.add(InventoryDevice(id="pd-seen", ip="192.168.1.77", suggested_type="server"))
await db_session.execute(
text("UPDATE nodes SET last_seen = :ts WHERE id = :id"),
{"ts": "2026-08-08 10:00:00.000000", "id": node["id"]},
text("UPDATE device_inventory SET last_seen = :ts WHERE id = :id"),
{"ts": "2026-08-08 10:00:00.000000", "id": node["device_id"]},
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
).json()["items"]
entry = next(i for i in items if i["id"] == "pd-seen")
entry = next(i for i in items if i["id"] == node["device_id"])
assert entry["node_last_seen"].endswith(("Z", "+00:00"))
async def test_prefers_the_node_the_mount_names(self, client: AsyncClient, headers):
@@ -630,9 +632,17 @@ class TestInventory:
assert entry["node_design_name"] is None
assert entry["services"] == []
async def test_falls_back_to_the_node_services(self, client: AsyncClient, headers, db_session):
"""An approved device keeps its fingerprint on the node, not the inventory row."""
async def test_reports_the_services_a_canvas_added(self, client: AsyncClient, headers, db_session):
"""Services documented on a canvas reach the rack — they are the device's.
The scan found the host but no services; the user typed one on the
logical canvas, and the rack prints it without the node being consulted.
"""
design_id = await _design(client, headers)
db_session.add(
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
)
await db_session.commit()
await client.post(
"/api/v1/nodes",
json={
@@ -643,10 +653,6 @@ class TestInventory:
},
headers=headers,
)
db_session.add(
InventoryDevice(id="pd-noservices", ip="192.168.1.63", suggested_type="server")
)
await db_session.commit()
items = (
await client.get(f"/api/v1/racks/inventory?design_id={design_id}", headers=headers)
+34 -20
View File
@@ -596,13 +596,20 @@ async def test_run_scan_adds_nmap_devices_as_pending(mem_db):
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
"""A scan that sees a device matching a canvas node (by IP) stamps last_scan."""
"""A scan that sees a known device stamps last_scan on its inventory row.
The stamp is a fact about the device, so it lands once on the row every
canvas draws — not once per node.
"""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n1", type="server", label="NAS", ip="192.168.1.5"))
device = InventoryDevice(id="d1", ip="192.168.1.5", status="approved")
session.add(device)
await session.flush()
session.add(Node(id="n1", type="server", label="NAS", device_id="d1"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": "nas.lan", "mac": None, "os": None, "open_ports": []}]
@@ -614,21 +621,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_ip(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n1")
device = await session.get(InventoryDevice, "d1")
assert node is not None
assert node.last_scan is not None
assert device is not None
assert device.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
"""A node with no IP but a matching MAC still gets last_scan stamped."""
"""A device with no IP but a matching MAC still gets last_scan stamped."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n2", type="iot", label="Sensor", mac="AA:BB:CC:DD:EE:FF"))
device = InventoryDevice(id="d2", mac="aa:bb:cc:dd:ee:ff", status="approved")
session.add(device)
await session.flush()
session.add(Node(id="n2", type="iot", label="Sensor", device_id="d2"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.9", "hostname": None, "mac": "AA:BB:CC:DD:EE:FF", "os": None, "open_ports": []}]
@@ -640,21 +650,24 @@ async def test_run_scan_stamps_last_scan_on_matching_node_by_mac(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n2")
device = await session.get(InventoryDevice, "d2")
assert node is not None
assert node.last_scan is not None
assert device is not None
assert device.last_scan is not None
@pytest.mark.asyncio
async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
"""A node whose IP/MAC is not seen by the scan keeps last_scan = None."""
"""A device whose IP/MAC the scan never saw keeps last_scan = None."""
from app.services.scanner import run_scan
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
session.add(Node(id="n3", type="server", label="Other", ip="10.0.0.99"))
device = InventoryDevice(id="d3", ip="10.0.0.99", status="approved")
session.add(device)
await session.flush()
session.add(Node(id="n3", type="server", label="Other", device_id="d3"))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.5", "hostname": None, "mac": None, "os": None, "open_ports": []}]
@@ -666,10 +679,10 @@ async def test_run_scan_leaves_last_scan_untouched_on_unmatched_node(mem_db):
await run_scan(["192.168.1.0/24"], session, run_id)
async with mem_db() as session:
node = await session.get(Node, "n3")
device = await session.get(InventoryDevice, "d3")
assert node is not None
assert node.last_scan is None
assert device is not None
assert device.last_scan is None
@pytest.mark.asyncio
@@ -771,11 +784,12 @@ async def test_run_scan_keeps_canvas_nodes(mem_db):
run_id = _make_run_id()
async with mem_db() as session:
session.add(_make_scan_run(run_id))
canvas_node = Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox",
ip="192.168.1.100", status="online",
)
session.add(canvas_node)
drawn = InventoryDevice(id="d-pve", ip="192.168.1.100", status="pending")
session.add(drawn)
await session.flush()
session.add(Node(
id=str(uuid.uuid4()), label="PVE", type="proxmox", device_id="d-pve",
))
await session.commit()
nmap_hosts = [{"ip": "192.168.1.100", "hostname": "pve.lan", "mac": None, "os": None, "open_ports": []}]
+91 -31
View File
@@ -24,18 +24,25 @@ from app.core.scheduler import (
stop_scheduler,
)
from app.db.database import Base
from app.db.models import Node
from app.db.models import InventoryDevice, Node
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_node(**kwargs) -> Node:
def _make_device(**kwargs) -> InventoryDevice:
"""An inventory row — what the checker iterates now that a device is checked
once rather than once per canvas."""
defaults = dict(id=str(uuid.uuid4()), status="approved", status_live="unknown")
return InventoryDevice(**{**defaults, **kwargs})
def _make_node(device_id: str | None = None, **kwargs) -> Node:
defaults = dict(
id=str(uuid.uuid4()),
type="server",
label="Test",
status="unknown",
device_id=device_id,
pos_x=0.0,
pos_y=0.0,
)
@@ -58,11 +65,10 @@ async def mem_db():
@pytest.mark.asyncio
async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
"""Nodes with no check_method are skipped; check_node is never called."""
async def test_run_status_checks_skips_devices_without_check_method(mem_db):
"""Devices with no check_method are skipped; check_node is never called."""
async with mem_db() as session:
node = _make_node(check_method=None, ip="10.0.0.1")
session.add(node)
session.add(_make_device(check_method=None, ip="10.0.0.1"))
await session.commit()
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
@@ -72,13 +78,16 @@ async def test_run_status_checks_skips_nodes_without_check_method(mem_db):
@pytest.mark.asyncio
async def test_run_status_checks_updates_node_status(mem_db):
"""check_node result is persisted to the DB and broadcast via WebSocket."""
async def test_run_status_checks_updates_device_status(mem_db):
"""check_node result lands on the device row and is broadcast to its nodes."""
async with mem_db() as session:
node = _make_node(check_method="ping", ip="10.0.0.1")
device = _make_device(check_method="ping", ip="10.0.0.1")
session.add(device)
await session.flush()
node = _make_node(device_id=device.id)
session.add(node)
await session.commit()
node_id = node.id
device_id, node_id = device.id, node.id
check_result = {"status": "online", "response_time_ms": 5}
@@ -87,28 +96,74 @@ async def test_run_status_checks_updates_node_status(mem_db):
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
await _run_status_checks()
# Verify DB updated
# Verify the row updated — `status_live` is reachability, `status` is the
# pending/approved/hidden lifecycle and must not be touched.
async with mem_db() as session:
updated = await session.get(Node, node_id)
updated = await session.get(InventoryDevice, device_id)
assert updated is not None
assert updated.status == "online"
assert updated.status_live == "online"
assert updated.status == "approved"
assert updated.response_time_ms == 5
# Verify WebSocket broadcast
# Verify WebSocket broadcast, addressed by device and carrying its nodes
mock_broadcast.assert_awaited_once()
_, kwargs = mock_broadcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["device_id"] == device_id
assert kwargs["node_ids"] == [node_id]
assert kwargs["status"] == "online"
@pytest.mark.asyncio
async def test_run_status_checks_covers_a_device_no_canvas_draws(mem_db):
"""Deliberate: the inventory row, not a node, is what gets monitored.
A rack mount or an inventory-only entry reports live status without being
drawn on any canvas, and deleting a node does not silently stop monitoring
the host. `hide` (or clearing the check method) is what ends the checks.
"""
async with mem_db() as session:
device = _make_device(check_method="ping", ip="10.0.0.1")
session.add(device)
await session.commit()
device_id = device.id
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", new_callable=AsyncMock,
return_value={"status": "online", "response_time_ms": 5}), \
patch("app.api.routes.status.broadcast_status", new_callable=AsyncMock) as mock_broadcast:
await _run_status_checks()
async with mem_db() as session:
updated = await session.get(InventoryDevice, device_id)
assert updated is not None
assert updated.status_live == "online"
_, kwargs = mock_broadcast.call_args
assert kwargs["device_id"] == device_id
assert kwargs["node_ids"] == []
@pytest.mark.asyncio
async def test_run_status_checks_skips_a_hidden_device(mem_db):
"""Hiding a device is how a user stops the checks, node or no node."""
async with mem_db() as session:
session.add(_make_device(check_method="ping", ip="10.0.0.1", status="hidden"))
await session.commit()
with patch("app.core.scheduler.AsyncSessionLocal", mem_db), \
patch("app.core.scheduler.check_node", new_callable=AsyncMock) as mock_check:
await _run_status_checks()
mock_check.assert_not_called()
@pytest.mark.asyncio
async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
"""last_seen is updated only when status is 'online'."""
async with mem_db() as session:
node = _make_node(check_method="ping", ip="10.0.0.1", last_seen=None)
session.add(node)
device = _make_device(check_method="ping", ip="10.0.0.1", last_seen=None)
session.add(device)
await session.commit()
node_id = node.id
device_id = device.id
check_result = {"status": "offline", "response_time_ms": None}
@@ -118,18 +173,19 @@ async def test_run_status_checks_sets_last_seen_only_when_online(mem_db):
await _run_status_checks()
async with mem_db() as session:
updated = await session.get(Node, node_id)
updated = await session.get(InventoryDevice, device_id)
assert updated is not None
assert updated.last_seen is None # not set for offline
@pytest.mark.asyncio
async def test_run_status_checks_handles_check_error_gracefully(mem_db):
"""An exception from check_node is logged and does not abort other nodes."""
"""An exception from check_node is logged and does not abort other devices."""
async with mem_db() as session:
n1 = _make_node(check_method="ping", ip="10.0.0.1")
n2 = _make_node(check_method="ping", ip="10.0.0.2")
session.add_all([n1, n2])
session.add_all([
_make_device(check_method="ping", ip="10.0.0.1"),
_make_device(check_method="ping", ip="10.0.0.2"),
])
await session.commit()
call_count = 0
@@ -191,7 +247,7 @@ def test_start_and_stop_scheduler():
@pytest.mark.asyncio
async def test_run_service_checks_disabled_does_nothing(mem_db):
async with mem_db() as session:
session.add(_make_node(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
session.add(_make_device(services=[{"port": 80, "protocol": "tcp", "service_name": "http"}]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
@@ -203,15 +259,18 @@ async def test_run_service_checks_disabled_does_nothing(mem_db):
@pytest.mark.asyncio
async def test_run_service_checks_broadcasts_per_node(mem_db):
async def test_run_service_checks_broadcasts_per_device(mem_db):
async with mem_db() as session:
node = _make_node(
device = _make_device(
ip="10.0.0.5",
services=[{"port": 80, "protocol": "tcp", "service_name": "http"}],
)
session.add(device)
await session.flush()
node = _make_node(device_id=device.id)
session.add(node)
await session.commit()
node_id = node.id
device_id, node_id = device.id, node.id
statuses = [{"port": 80, "protocol": "tcp", "status": "offline"}]
@@ -224,14 +283,15 @@ async def test_run_service_checks_broadcasts_per_node(mem_db):
mock_bcast.assert_awaited_once()
_, kwargs = mock_bcast.call_args
assert kwargs["node_id"] == node_id
assert kwargs["device_id"] == device_id
assert kwargs["node_ids"] == [node_id]
assert kwargs["services"] == statuses
@pytest.mark.asyncio
async def test_run_service_checks_skips_nodes_without_services(mem_db):
async def test_run_service_checks_skips_devices_without_services(mem_db):
async with mem_db() as session:
session.add(_make_node(ip="10.0.0.6", services=[]))
session.add(_make_device(ip="10.0.0.6", services=[]))
await session.commit()
with patch("app.core.scheduler.settings") as mock_settings, \
+27
View File
@@ -37,6 +37,33 @@ async def test_update_settings_saves_interval(client: AsyncClient, headers):
assert res.json()["interval_seconds"] == 120
@pytest.mark.asyncio
async def test_update_settings_reschedules_status_checks(client: AsyncClient, headers):
"""The new interval must reach the running scheduler, not just the config
file: otherwise the UI and scan_config.json show the new value while the
job keeps firing at the old one until the backend restarts."""
with patch("app.api.routes.settings.settings") as mock_settings, \
patch("app.api.routes.settings.reschedule_status_checks") as mock_reschedule:
mock_settings.save_overrides = lambda: None
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 120},
headers=headers,
)
assert res.status_code == 200
mock_reschedule.assert_called_once_with(120)
@pytest.mark.asyncio
async def test_update_settings_rejects_too_short_status_interval(client: AsyncClient, headers):
res = await client.post(
"/api/v1/settings",
json={"interval_seconds": 5},
headers=headers,
)
assert res.status_code == 422
@pytest.mark.asyncio
async def test_update_settings_requires_auth(client: AsyncClient):
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
+8 -1
View File
@@ -7,7 +7,7 @@ from app.services.fingerprint import _load, match_service
_NODE_TYPES = {
"isp", "router", "switch", "server", "proxmox", "vm", "lxc",
"nas", "iot", "ap", "camera", "generic",
"nas", "kvm", "iot", "ap", "camera", "generic",
}
@@ -43,6 +43,13 @@ def test_popular_apps_have_port_agnostic_signatures(signatures):
assert expected in names, f"missing port-agnostic signature for {expected}"
@pytest.mark.parametrize("title", ["PiKVM", "TinyPilot", "JetKVM", "NanoKVM"])
def test_ip_kvm_titles_suggest_kvm_node_type(title):
sig = match_service(80, "tcp", banner=None, http_signals={"title": title, "headers": {}})
assert sig is not None
assert sig["suggested_node_type"] == "kvm"
@pytest.mark.parametrize(("title", "expected"), [
("Jellyfin", "Jellyfin"),
("Home Assistant", "Home Assistant"),
+14 -6
View File
@@ -68,13 +68,21 @@ async def test_summary_aggregates_counts(
) -> None:
settings.homepage_api_key = "topsecret"
finished = datetime(2026, 5, 14, 10, 0, tzinfo=timezone.utc)
# Reachability and the ieee live on the inventory row each node draws.
drawn = [
("A", "server", "online", None),
("B", "server", "online", None),
("C", "server", "offline", None),
("D", "server", "unknown", None),
("Z1", "iot", "online", "0x1"),
("Z2", "iot", "online", "0x2"),
]
for label, node_type, status_live, ieee in drawn:
device = InventoryDevice(status="approved", status_live=status_live, ieee_address=ieee)
db_session.add(device)
await db_session.flush()
db_session.add(Node(type=node_type, label=label, device_id=device.id))
db_session.add_all([
Node(type="server", label="A", status="online"),
Node(type="server", label="B", status="online"),
Node(type="server", label="C", status="offline"),
Node(type="server", label="D", status="unknown"),
Node(type="iot", label="Z1", status="online", ieee_address="0x1"),
Node(type="iot", label="Z2", status="online", ieee_address="0x2"),
InventoryDevice(ip="10.0.0.1", status="pending"),
InventoryDevice(ip="10.0.0.2", status="pending"),
InventoryDevice(ip="10.0.0.3", status="hidden"), # excluded
+14 -9
View File
@@ -74,7 +74,8 @@ async def test_broadcast_status_sends_to_connected_clients():
_connections.append(fake)
try:
await broadcast_status(
node_id="node-1",
device_id="dev-1",
node_ids=["node-1", "node-2"],
status="online",
checked_at="2024-01-01T00:00:00",
response_time_ms=42,
@@ -85,7 +86,9 @@ async def test_broadcast_status_sends_to_connected_clients():
assert len(received) == 1
msg = json.loads(received[0])
assert msg["type"] == "status"
assert msg["node_id"] == "node-1"
# One check, addressed by device, lighting up every canvas drawing it.
assert msg["device_id"] == "dev-1"
assert msg["node_ids"] == ["node-1", "node-2"]
assert msg["status"] == "online"
assert msg["response_time_ms"] == 42
@@ -102,7 +105,7 @@ async def test_broadcast_status_no_response_time():
fake = FakeWS()
_connections.append(fake)
try:
await broadcast_status(node_id="n", status="offline", checked_at="t")
await broadcast_status(device_id="d", node_ids=["n"], status="offline", checked_at="t")
finally:
_connections.remove(fake)
@@ -122,7 +125,7 @@ async def test_broadcast_status_removes_dead_connection():
_connections.append(dead)
initial_len = len(_connections)
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
assert dead not in _connections
assert len(_connections) == initial_len - 1
@@ -159,7 +162,7 @@ async def test_broadcast_scan_update():
async def test_broadcast_no_connections():
"""broadcast_* with no connections must not raise."""
assert len(_connections) == 0
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
await broadcast_scan_update(run_id="r", devices_found=0)
@@ -179,7 +182,8 @@ async def test_broadcast_service_status_payload():
_connections.append(fake)
try:
await broadcast_service_status(
node_id="node-7",
device_id="dev-7",
node_ids=["node-7"],
services=[{"port": 80, "protocol": "tcp", "status": "offline"}],
checked_at="2024-01-01T00:00:00",
)
@@ -188,7 +192,8 @@ async def test_broadcast_service_status_payload():
msg = json.loads(received[0])
assert msg["type"] == "service_status"
assert msg["node_id"] == "node-7"
assert msg["device_id"] == "dev-7"
assert msg["node_ids"] == ["node-7"]
assert msg["services"] == [{"port": 80, "protocol": "tcp", "status": "offline"}]
@@ -217,7 +222,7 @@ async def test_broadcast_dead_connection_dropped_once_safely():
dead = DeadWS()
_connections.append(dead)
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
# A second broadcast must not raise even though dead is already gone.
await broadcast_status(node_id="n", status="online", checked_at="t")
await broadcast_status(device_id="d", node_ids=["n"], status="online", checked_at="t")
assert dead not in _connections
+77 -2
View File
@@ -467,11 +467,86 @@ async def test_check_services_returns_status_per_service():
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 80, "protocol": "tcp", "status": "online"},
{"port": 5432, "protocol": "tcp", "status": "unknown"},
{"port": 80, "protocol": "tcp", "host": None, "status": "online"},
{"port": 5432, "protocol": "tcp", "host": None, "status": "unknown"},
]
@pytest.mark.asyncio
async def test_check_services_echoes_the_host_override():
services = [{"port": 443, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}]
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
results = await check_services("10.0.0.1", services)
assert results == [
{"port": 443, "protocol": "tcp", "host": "blog.example.com", "status": "online"},
]
# --- Per-service host override ---
async def _captured_url(svc, host):
captured = {}
async def fake_http_get(url, verify=False):
captured["url"] = url
return True
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
await check_service(svc, host)
return captured.get("url")
@pytest.mark.asyncio
async def test_check_service_uses_the_host_override():
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com"}
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
@pytest.mark.asyncio
async def test_check_service_falls_back_to_the_node_host_when_override_is_blank():
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": " "}
assert await _captured_url(svc, "10.0.0.1") == "http://10.0.0.1:8080"
@pytest.mark.asyncio
async def test_check_service_honours_a_scheme_in_the_override():
svc = {"protocol": "tcp", "service_name": "blog", "host": "https://blog.example.com"}
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com"
@pytest.mark.asyncio
async def test_check_service_takes_the_port_from_the_override():
svc = {"protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
assert await _captured_url(svc, "10.0.0.1") == "https://blog.example.com:8443"
@pytest.mark.asyncio
async def test_check_service_lets_the_service_port_beat_the_override_port():
svc = {"port": 3000, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com:8443"}
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:3000"
@pytest.mark.asyncio
async def test_check_service_uses_the_first_host_of_a_comma_list_override():
svc = {"port": 8080, "protocol": "tcp", "service_name": "blog", "host": "blog.example.com, alt.example.com"}
assert await _captured_url(svc, "10.0.0.1") == "http://blog.example.com:8080"
@pytest.mark.asyncio
async def test_check_service_brackets_an_ipv6_override():
svc = {"port": 80, "protocol": "tcp", "service_name": "http", "host": "[2001:db8::1]:8080"}
assert await _captured_url(svc, "10.0.0.1") == "http://[2001:db8::1]:80"
@pytest.mark.asyncio
async def test_check_service_skips_a_non_http_port_behind_an_override():
svc = {"port": 5432, "protocol": "tcp", "service_name": "postgres", "host": "db.example.com"}
with patch("app.services.status_checker._http_get", new_callable=AsyncMock) as mock_get:
assert await check_service(svc, "10.0.0.1") == "unknown"
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_check_services_empty_list():
assert await check_services("10.0.0.1", []) == []
+55 -34
View File
@@ -436,15 +436,18 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
# Simulate: router was approved earlier → exists as a canvas Node, but with
# no matching device_inventory row (e.g. a legacy auto-placed device).
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
device = InventoryDevice(
ieee_address="0xR1",
services=[],
friendly_name="router_1",
suggested_type="zigbee_router",
status="approved",
status_live="online",
check_method="none",
properties=[],
)
db_session.add(device)
await db_session.flush()
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
db_session.add(approved)
await db_session.commit()
@@ -464,7 +467,9 @@ async def test_persist_pending_import_backfills_inventory_for_approved_node(
# Node properties got refreshed.
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"IEEE": "0xR1", "Vendor": "TI", "Model": "CC2530", "LQI": "250"}
@@ -483,14 +488,15 @@ async def test_persist_pending_import_preserves_hidden_inventory_for_approved_no
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import InventoryDevice, Node
db_session.add(Node(
label="router_1", type="zigbee_router", status="online",
check_method="none", ieee_address="0xR1", services=[], properties=[],
))
db_session.add(InventoryDevice(
# One row, hidden by the user, and a canvas still draws it.
drawn = InventoryDevice(
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
device_subtype="Router", status="hidden", discovery_source="zigbee",
))
device_subtype="Router", status="hidden", status_live="online",
check_method="none", discovery_source="zigbee",
)
db_session.add(drawn)
await db_session.flush()
db_session.add(Node(label="router_1", type="zigbee_router", device_id=drawn.id))
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
@@ -595,21 +601,24 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Node
from app.db.models import InventoryDevice, Node
approved = Node(
label="router_1",
type="zigbee_router",
status="online",
check_method="none",
device = InventoryDevice(
ieee_address="0xR1",
services=[],
friendly_name="router_1",
suggested_type="zigbee_router",
status="approved",
status_live="online",
check_method="none",
properties=[
{"key": "IEEE", "value": "0xR1", "icon": None, "visible": True},
{"key": "Vendor", "value": "TI", "icon": None, "visible": True},
{"key": "Custom", "value": "kept", "icon": None, "visible": True},
],
)
db_session.add(device)
await db_session.flush()
approved = Node(label="router_1", type="zigbee_router", device_id=device.id)
db_session.add(approved)
await db_session.commit()
@@ -619,7 +628,9 @@ async def test_persist_pending_import_preserves_user_visibility(db_session) -> N
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xR1")
)
).scalar_one()
by_key = {p["key"]: p for p in refreshed.properties}
# Existing keys keep their visibility (True).
@@ -647,10 +658,14 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
from app.db.models import InventoryDevice, Node
# Legacy: coordinator auto-placed as a canvas Node, no device_inventory row.
db_session.add(Node(
label="Coordinator", type="zigbee_coordinator", status="online",
check_method="none", ieee_address="0xCOORD", services=[], properties=[],
))
drawn = InventoryDevice(
ieee_address="0xCOORD", friendly_name="Coordinator",
suggested_type="zigbee_coordinator", status="approved", status_live="online",
check_method="none",
)
db_session.add(drawn)
await db_session.flush()
db_session.add(Node(label="Coordinator", type="zigbee_coordinator", device_id=drawn.id))
await db_session.commit()
bumped = [dict(n) for n in _PENDING_NODES]
@@ -659,7 +674,9 @@ async def test_persist_pending_import_refreshes_approved_coordinator_node(
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
coord = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xCOORD"))
await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == "0xCOORD")
)
).scalar_one()
keys = {p["key"]: p["value"] for p in coord.properties}
assert keys["Vendor"] == "TI"
@@ -685,17 +702,19 @@ async def test_persist_pending_import_device_on_multiple_canvases(
from sqlalchemy import select
from app.api.routes.zigbee import _persist_pending_import
from app.db.models import Design, Node
from app.db.models import Design, InventoryDevice, Node
d1 = Design(name="d1")
d2 = Design(name="d2")
db_session.add_all([d1, d2])
drawn = InventoryDevice(
ieee_address="0xR1", friendly_name="router_1", suggested_type="zigbee_router",
status="approved", status_live="online", check_method="none",
)
db_session.add_all([d1, d2, drawn])
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,
label="router_1", type="zigbee_router", device_id=drawn.id, design_id=d.id,
))
await db_session.commit()
@@ -705,10 +724,12 @@ async def test_persist_pending_import_device_on_multiple_canvases(
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "0xR1"))
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
).scalars().all()
assert len(nodes) == 2 # both canvas placements preserved
for n in nodes:
# One row behind them, so one refresh serves both canvases.
await db_session.refresh(drawn)
for n in [drawn]:
lqi = {p["key"]: p["value"] for p in n.properties}.get("LQI")
assert lqi == "240" # refreshed on every canvas
+22 -18
View File
@@ -374,16 +374,14 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
from app.api.routes.zwave import _persist_pending_import
from app.db.models import InventoryDevice, Node
approved = Node(
label="Wall Plug",
type="zwave_router",
status="online",
drawn = InventoryDevice(
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
suggested_type="zwave_router", status="approved", status_live="online",
check_method="none",
ieee_address="zwave-0xh-2",
services=[],
properties=[],
)
db_session.add(approved)
db_session.add(drawn)
await db_session.flush()
db_session.add(Node(label="Wall Plug", type="zwave_router", device_id=drawn.id))
await db_session.commit()
await _persist_pending_import(db_session, _PENDING_NODES, _PENDING_EDGES)
@@ -396,7 +394,9 @@ async def test_persist_backfills_inventory_for_approved_node(db_session) -> None
assert inv.status == "approved"
assert inv.suggested_type == "zwave_router"
refreshed = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
await db_session.execute(
select(InventoryDevice).where(InventoryDevice.ieee_address == "zwave-0xh-2")
)
).scalar_one()
keys = {p["key"]: p["value"] for p in refreshed.properties}
assert keys == {"Z-Wave ID": "zwave-0xh-2", "Vendor": "Aeotec", "Model": "ZW100"}
@@ -466,17 +466,20 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
from sqlalchemy import select
from app.api.routes.zwave import _persist_pending_import
from app.db.models import Design, Node
from app.db.models import Design, InventoryDevice, Node
d1 = Design(name="d1")
d2 = Design(name="d2")
db_session.add_all([d1, d2])
drawn = InventoryDevice(
ieee_address="zwave-0xh-2", friendly_name="Wall Plug",
suggested_type="zwave_router", status="approved", status_live="online",
check_method="none",
)
db_session.add_all([d1, d2, drawn])
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,
label="Wall Plug", type="zwave_router", device_id=drawn.id, design_id=d.id,
))
await db_session.commit()
@@ -486,12 +489,13 @@ async def test_persist_device_on_multiple_canvases(db_session) -> None:
await _persist_pending_import(db_session, bumped, _PENDING_EDGES)
nodes = (
await db_session.execute(select(Node).where(Node.ieee_address == "zwave-0xh-2"))
await db_session.execute(select(Node).where(Node.device_id == drawn.id))
).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
# One row behind them, so one refresh serves every canvas.
await db_session.refresh(drawn)
model = {p["key"]: p["value"] for p in drawn.properties}.get("Model")
assert model == "ZW200"
# ---------------------------------------------------------------------------
+8 -8
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "3.2.0",
"version": "3.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "3.2.0",
"version": "3.3.1",
"dependencies": {
"@base-ui/react": "^1.2.0",
"@dagrejs/dagre": "^2.0.4",
@@ -6578,9 +6578,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9514,9 +9514,9 @@
}
},
"node_modules/undici": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "3.2.0",
"version": "3.3.1",
"type": "module",
"scripts": {
"dev": "vite",
+32 -5
View File
@@ -64,7 +64,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export default function App() {
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, addToZone, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated, isInitialized } = useAuthStore()
const authBootstrapStarted = useRef(false)
@@ -133,6 +133,7 @@ export default function App() {
const [pendingConnection, setPendingConnection] = useState<Connection | null>(null)
const [pendingGroupAdd, setPendingGroupAdd] = useState<{ nodeId: string; groupId: string } | null>(null)
const [pendingContainerAdd, setPendingContainerAdd] = useState<{ nodeId: string; containerId: string } | null>(null)
const [pendingZoneAdd, setPendingZoneAdd] = useState<{ nodeId: string; zoneId: string } | null>(null)
const [editEdgeId, setEditEdgeId] = useState<string | null>(null)
const [scanConfigOpen, setScanConfigOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
@@ -167,7 +168,10 @@ export default function App() {
if (!options?.silent) toast.success('Canvas saved')
return true
}
const nodesToSave = nodes.map(serializeNode)
// Read the baseline at save time (not through the render-time destructure)
// so a device edited in the inventory a moment ago is already rebased.
const factsBaseline = useCanvasStore.getState().factsBaseline
const nodesToSave = nodes.map((n) => serializeNode(n, factsBaseline[n.id]))
const edgesToSave = edges.map(serializeEdge)
const viewport: Record<string, unknown> = { theme_id: activeTheme }
if (floorMap) viewport.floor_map = floorMap
@@ -221,8 +225,11 @@ export default function App() {
.filter((n) => n.type === 'group' || n.container_mode === true)
.map((n) => [n.id, true])
)
const zoneIds = new Set(
(apiNodes as ApiNode[]).filter((n) => n.type === 'groupRect').map((n) => n.id)
)
const { nodes: rfNodes, edges: rfEdges } = migrateClusterHandles(
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap)),
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxContainerMap, zoneIds)),
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
)
const savedTheme = res.data.viewport?.theme_id
@@ -498,7 +505,9 @@ export default function App() {
// parent the LXC/VM stays a free node (linked by a virtual edge) — setting
// extent:'parent' on a non-container would trap it inside the parent's tiny
// bounding box with no way to drag it out (issue #205 follow-up).
const nestInParent = !!parentNode?.data.container_mode
// A visual group nests too, so it seeds its position the same way — mirrors
// the condition in the store's addNode, which is the authority here.
const nestInParent = !!parentNode?.data.container_mode || parentNode?.data.type === 'group'
// Seed an ABSOLUTE position near the container's top-left; addNode converts
// it to container-relative. addNode is the single authority for parentId /
// extent, so we don't set them here.
@@ -1048,6 +1057,7 @@ export default function App() {
onNodeDragStart={snapshotHistory}
onRequestAddToGroup={setPendingGroupAdd}
onRequestAddToContainer={setPendingContainerAdd}
onRequestAddToZone={setPendingZoneAdd}
onOpenInventory={(deviceId) => openInventoryModal(deviceId)}
/>
)}
@@ -1058,7 +1068,12 @@ export default function App() {
{isRackDesign
? <RackCablePanel />
: (selectedNodeId || selectedNodeIds.length > 1) && (
<DetailPanel onEdit={handleEditNode} />
<DetailPanel
onEdit={handleEditNode}
// Standalone has no Device Inventory to open (ADR-001 style
// gate: the whole scan surface is backend-only).
onOpenInventory={STANDALONE ? undefined : (deviceId) => openInventoryModal(deviceId)}
/>
)}
</div>
</div>
@@ -1285,6 +1300,18 @@ export default function App() {
onCancel={() => setPendingContainerAdd(null)}
/>
<ConfirmAddToGroupModal
open={!!pendingZoneAdd}
variant="zone"
nodeLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.nodeId)?.data.label ?? '') : ''}
targetLabel={pendingZoneAdd ? (nodes.find((n) => n.id === pendingZoneAdd.zoneId)?.data.label ?? '') : ''}
onConfirm={() => {
if (pendingZoneAdd) addToZone(pendingZoneAdd.zoneId, pendingZoneAdd.nodeId)
setPendingZoneAdd(null)
}}
onCancel={() => setPendingZoneAdd(null)}
/>
{/* Mounted in standalone too: status-check settings are hidden inside,
but canvas prefs (snap, hide-IP) still apply. */}
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
+8
View File
@@ -1,6 +1,7 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
import type { InventoryEntry } from '@/types'
export const api = axios.create({
baseURL: '/api/v1',
@@ -150,6 +151,13 @@ export const scanApi = {
/** "manual" (default) or "rack" for gear created from a rack canvas. */
discovery_source?: 'manual' | 'rack'
}) => api.post<{ id: string; hostname: string | null }>('/scan/pending', data),
/**
* Edit an inventory row. Partial: only the keys sent are applied, so a caller
* touching one field never clears the rest. Lifecycle (`status`) and discovery
* bookkeeping are not editable — approve/hide own those.
*/
updatePending: (id: string, data: Partial<Omit<InventoryEntry, 'id' | 'status' | 'discovered_at'>>) =>
api.patch<InventoryEntry>(`/scan/pending/${id}`, data),
hidden: () => api.get('/scan/hidden'),
runs: () => api.get('/scan/runs'),
clearPending: () => api.delete('/scan/pending'),
@@ -34,10 +34,11 @@ interface CanvasContainerProps {
onNodeDragStart?: () => void
onRequestAddToGroup?: (payload: { nodeId: string; groupId: string }) => void
onRequestAddToContainer?: (payload: { nodeId: string; containerId: string }) => void
onRequestAddToZone?: (payload: { nodeId: string; zoneId: string }) => void
onOpenInventory?: (deviceId: string) => void
}
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onOpenInventory }: CanvasContainerProps) {
export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, onNodeDoubleClick, onNodeDragStart, onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, onOpenInventory }: CanvasContainerProps) {
const [lassoMode, setLassoMode] = useState(true)
const {
nodes, edges,
@@ -45,6 +46,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
setSelectedNode, snapshotHistory,
fitViewPending, clearFitViewPending,
copySelectedNodes, pasteNodes,
removeFromGroup,
} = useCanvasStore()
const { fitView, screenToFlowPosition, getIntersectingNodes } = useReactFlow<Node<NodeData>>()
@@ -146,20 +148,35 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
// Drop a top-level node onto a group → ask App to confirm adding it. Runs
// before the alignment snap so detection uses the dropped position.
const handleNodeDragStop = useCallback<NonNullable<typeof onNodeDragStop>>((event, dragNode, dragNodes) => {
if (dragNode && !dragNode.parentId &&
dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
if (dragNode && dragNode.data.type !== 'group' && dragNode.data.type !== 'groupRect') {
const intersecting = getIntersectingNodes(dragNode)
const group = intersecting.find((n) => n.data.type === 'group')
if (group) {
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
} else {
// Any node in container_mode (proxmox, docker_host, …) accepts children.
const zoneParent = dragNode.parentId
? nodes.find((n) => n.id === dragNode.parentId && n.data.type === 'groupRect')
: undefined
if (zoneParent) {
// Zone children are not extent-clamped, so a drop outside the zone is
// how the user takes a node back out of it.
if (!intersecting.some((n) => n.id === zoneParent.id)) {
removeFromGroup(zoneParent.id, dragNode.id)
}
} else if (!dragNode.parentId) {
const group = intersecting.find((n) => n.data.type === 'group')
const container = intersecting.find((n) => n.id !== dragNode.id && n.data.container_mode === true)
if (container) onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
if (group) {
onRequestAddToGroup?.({ nodeId: dragNode.id, groupId: group.id })
} else if (container) {
// Any node in container_mode (proxmox, docker_host, …) accepts children.
onRequestAddToContainer?.({ nodeId: dragNode.id, containerId: container.id })
} else {
// Zones come last: they are the loosest container and the largest, so
// a group/container inside one still wins the drop.
const zone = intersecting.find((n) => n.data.type === 'groupRect')
if (zone) onRequestAddToZone?.({ nodeId: dragNode.id, zoneId: zone.id })
}
}
}
onNodeDragStop(event, dragNode, dragNodes)
}, [onRequestAddToGroup, onRequestAddToContainer, getIntersectingNodes, onNodeDragStop])
}, [onRequestAddToGroup, onRequestAddToContainer, onRequestAddToZone, removeFromGroup, nodes, getIntersectingNodes, onNodeDragStop])
return (
<div ref={wrapperRef} className="w-full h-full" style={{ background: theme.colors.canvasBackground }} onMouseMove={onMouseMove}>
@@ -251,6 +251,63 @@ describe('CanvasContainer', () => {
expect(onRequestAddToContainer).not.toHaveBeenCalled()
})
// ── Drag onto a zone → onRequestAddToZone / detach ────────────────────────
function zoneNode(id: string): Node<NodeData> {
return { id, type: 'groupRect', position: { x: 0, y: 0 }, data: { label: id, type: 'groupRect', status: 'unknown', services: [] } }
}
it('fires onRequestAddToZone when a node is dropped over a zone', () => {
const onRequestAddToZone = vi.fn()
const node = makeNode('n1')
rf.intersecting = [zoneNode('z1')]
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToZone).toHaveBeenCalledWith({ nodeId: 'n1', zoneId: 'z1' })
})
it('prefers a group and a container over a zone when they overlap', () => {
const onRequestAddToZone = vi.fn()
const onRequestAddToContainer = vi.fn()
const node = makeNode('n1')
rf.intersecting = [zoneNode('z1'), containerNode('px1')]
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} onRequestAddToContainer={onRequestAddToContainer} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToContainer).toHaveBeenCalledWith({ nodeId: 'n1', containerId: 'px1' })
expect(onRequestAddToZone).not.toHaveBeenCalled()
})
it('does not fire onRequestAddToZone when the dragged node is a zone itself', () => {
const onRequestAddToZone = vi.fn()
const node = zoneNode('z2')
rf.intersecting = [zoneNode('z1')]
render(<CanvasContainer onRequestAddToZone={onRequestAddToZone} />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, node, [node])
expect(onRequestAddToZone).not.toHaveBeenCalled()
})
it('detaches a zone child dropped outside its zone', () => {
const zone = { ...zoneNode('z1'), position: { x: 100, y: 100 } }
const child = { ...makeNode('n1'), parentId: 'z1', position: { x: 20, y: 20 } }
useCanvasStore.setState({ nodes: [zone, child] })
rf.intersecting = []
render(<CanvasContainer />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, child, [child])
const after = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(after.parentId).toBeUndefined()
expect(after.position).toEqual({ x: 120, y: 120 })
})
it('keeps a zone child parented when it is dropped inside its zone', () => {
const zone = zoneNode('z1')
const child = { ...makeNode('n1'), parentId: 'z1' }
useCanvasStore.setState({ nodes: [zone, child] })
rf.intersecting = [zone]
render(<CanvasContainer />)
;(rfProps.onNodeDragStop as (...args: unknown[]) => unknown)({} as MouseEvent, child, [child])
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBe('z1')
})
// ── Canvas settings ───────────────────────────────────────────────────────
it('enables snapToGrid', () => {
@@ -148,7 +148,9 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div key={prop.key} className="flex items-center gap-1 font-mono text-[10px] min-w-0 overflow-hidden" style={{ color: theme.colors.nodeSubtextColor }}>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
{prop.value.trim() && (
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
)}
</div>
)
})}
@@ -162,7 +164,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
<div className="flex flex-col gap-1 px-2.5 py-1.5 overflow-hidden">
{services.map((svc, idx) => {
const url = getServiceUrl(svc, serviceHost)
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol)] === 'offline'
const svcOffline = serviceStatuses[serviceStatusKey(id, svc.port, svc.protocol, svc.host)] === 'offline'
const row = (
<div
className="nodrag flex items-center justify-between gap-2 px-1.5 py-1 rounded text-[10px] min-w-0 overflow-hidden"
@@ -2,7 +2,7 @@ import { useState } from 'react'
import { Handle, Position, NodeResizer, type NodeProps, type Node } from '@xyflow/react'
import { ChevronDown } from 'lucide-react'
import { useCanvasStore } from '@/stores/canvasStore'
import { getZoneSpatialChildren } from '@/utils/collapseFilter'
import { getZoneChildren } from '@/utils/collapseFilter'
import type { NodeData, TextPosition } from '@/types'
const FONT_FAMILIES: Record<string, string> = {
@@ -55,11 +55,11 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
const textPos = (rc.text_position ?? 'top-left') as TextPosition
const posStyle = POSITION_STYLES[textPos]
// Count children for collapse badge — groupRect zones don't parent their
// contents via React Flow parentId, so we hit-test by spatial containment.
// Count children for collapse badge — a zone holds both real parentId
// children (dropped inside) and top-level nodes merely overlapping it.
const selfNode = (nodes ?? []).find((n) => n.id === id)
const childrenCount = selfNode
? getZoneSpatialChildren(selfNode, nodes ?? []).length
? getZoneChildren(selfNode, nodes ?? []).length
: 0
const outsideJustify = textPos.includes('right') ? 'flex-end'
@@ -120,7 +120,9 @@ export function ProxmoxGroupNode(props: NodeProps<Node<NodeData>>) {
>
{Icon && <Icon size={9} className="shrink-0" />}
<span className="truncate max-w-15 shrink-0" title={prop.key}>{prop.key}</span>
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
{prop.value.trim() && (
<span className="truncate min-w-0" title={prop.value}>· {prop.value}</span>
)}
</div>
)
})}
@@ -1,7 +1,23 @@
import { describe, it, expect } from 'vitest'
import { nodeTypes } from '../nodeTypes'
import { NODE_TYPE_LABELS, type NodeType } from '@/types'
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
describe('nodeTypes registry', () => {
it('registers a component for every declared node type', () => {
// Adding a NodeType touches several hand-written maps; the Record<NodeType, …>
// ones fail typecheck when one is missed, this registry does not.
for (const t of Object.keys(NODE_TYPE_LABELS) as NodeType[]) {
expect(nodeTypes[t as keyof typeof nodeTypes], `missing nodeType: ${t}`).toBeDefined()
}
})
it('gives the KVM type a label, an icon and a component', () => {
expect(NODE_TYPE_LABELS.kvm).toBe('KVM Switch')
expect(NODE_TYPE_DEFAULT_ICONS.kvm).toBeDefined()
expect(nodeTypes.kvm).toBeDefined()
})
it('registers a component for every wireless mesh node type', () => {
// Regression: zwave_* types were missing, so React Flow fell back to the
// default (unstyled) node — no icon, no accent. (Zigbee covered too.)
@@ -1,7 +1,7 @@
import { type NodeProps, type Node } from '@xyflow/react'
import {
Globe, Router, Network, Server, Layers, Box, Container,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
HardDrive, Cpu, Wifi, Circle, Cctv, Printer, Monitor, MonitorCog, Laptop, Smartphone, PlugZap, Anchor, Package, Flame, Radio, Antenna, RadioTower, Share2,
Grid3x3, Battery, Fuel, Sun, Repeat2, Split, ToggleLeft, Lightbulb, Gauge, Combine, Cable, Zap,
} from 'lucide-react'
import { BaseNode } from './BaseNode'
@@ -18,6 +18,7 @@ export const ProxmoxNode = (props: N) => <BaseNode {...props} icon={Layers} />
export const VmNode = (props: N) => <BaseNode {...props} icon={Box} />
export const LxcNode = (props: N) => <BaseNode {...props} icon={Container} />
export const NasNode = (props: N) => <BaseNode {...props} icon={HardDrive} />
export const KvmNode = (props: N) => <BaseNode {...props} icon={MonitorCog} />
export const IotNode = (props: N) => <BaseNode {...props} icon={Cpu} />
export const ApNode = (props: N) => <BaseNode {...props} icon={Wifi} />
export const CameraNode = (props: N) => <BaseNode {...props} icon={Cctv} />
@@ -1,6 +1,6 @@
import {
IspNode, RouterNode, FirewallNode, SwitchNode, ServerNode, VmNode, LxcNode,
NasNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
NasNode, KvmNode, IotNode, ApNode, CameraNode, PrinterNode, ComputerNode, LaptopNode,
MobileNode, CplNode, DockerHostNode, DockerContainerNode, GenericNode,
ZigbeeCoordinatorNode, ZigbeeRouterNode, ZigbeeEndDeviceNode,
ZwaveCoordinatorNode, ZwaveRouterNode, ZwaveEndDeviceNode,
@@ -23,6 +23,7 @@ export const nodeTypes = {
vm: VmNode,
lxc: LxcNode,
nas: NasNode,
kvm: KvmNode,
iot: IotNode,
ap: ApNode,
camera: CameraNode,
@@ -38,7 +38,7 @@ export function PropertyList({
onChange,
visibleLabel = 'Show on node',
keyPlaceholder = 'Label (e.g. CPU Model)',
valuePlaceholder = 'Value (e.g. i7-12700K)',
valuePlaceholder = 'Value — optional (e.g. i7-12700K)',
suggestions,
title = 'Properties',
emptyHint = 'No properties — click Add to define one.',
@@ -51,7 +51,8 @@ export function PropertyList({
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const handleAdd = () => {
if (!newProp.key.trim() || !newProp.value.trim()) return
// Only the key is required — a property may carry a label alone.
if (!newProp.key.trim()) return
onChange([
...properties,
{ key: newProp.key.trim(), value: newProp.value.trim(), icon: newProp.icon, visible: newProp.visible },
@@ -82,7 +83,7 @@ export function PropertyList({
}
const handleSaveEdit = () => {
if (editingIndex === null || !editProp.key.trim() || !editProp.value.trim()) return
if (editingIndex === null || !editProp.key.trim()) return
onChange(
properties.map((p, i) =>
i === editingIndex
@@ -192,7 +193,7 @@ export function PropertyList({
)
}
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value (e.g. i7-12700K)' }: {
export function PropertyForm({ form, onChange, onConfirm, onCancel, confirmLabel, visibleLabel = 'Show on node', keyPlaceholder = 'Label (e.g. CPU Model)', valuePlaceholder = 'Value — optional (e.g. i7-12700K)' }: {
form: PropForm
onChange: (f: PropForm) => void
onConfirm: () => void
@@ -307,7 +308,9 @@ export function PropertyBadge({ prop, visibleLabel = 'node', draggable, isDraggi
)}
{Icon && createElement(Icon, { size: 11, className: 'shrink-0 text-muted-foreground' })}
<span className="font-medium truncate text-foreground" title={prop.key}>{prop.key}</span>
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
{prop.value.trim() && (
<span className="text-muted-foreground truncate" title={prop.value}>· {prop.value}</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
@@ -40,7 +40,7 @@ describe('PropertyList', () => {
])
})
it('refuses a property with no key or no value', () => {
it('adds a property with a label and no value', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
@@ -48,9 +48,27 @@ describe('PropertyList', () => {
fireEvent.change(screen.getByPlaceholderText(/^Label/), { target: { value: 'GPU' } })
fireEvent.click(confirm('Add'))
expect(onChange).toHaveBeenCalledWith([{ key: 'GPU', value: '', icon: null, visible: true }])
})
it('refuses a property with no key', () => {
const onChange = vi.fn()
render(<PropertyList properties={[]} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX' } })
fireEvent.click(confirm('Add'))
expect(onChange).not.toHaveBeenCalled()
})
it('prints a value-less property as a bare label', () => {
render(<PropertyList properties={[{ key: 'Rented', value: '', icon: null, visible: true }]} onChange={vi.fn()} />)
expect(screen.getByTitle('Rented')).toBeInTheDocument()
expect(screen.queryByText(/^·/)).not.toBeInTheDocument()
})
it('toggles a property between shown and hidden', () => {
const onChange = vi.fn()
render(<PropertyList properties={props()} onChange={onChange} />)
@@ -110,6 +128,17 @@ describe('PropertyList', () => {
])
})
it('saves an edit that clears the value', () => {
const onChange = vi.fn()
render(<PropertyList properties={props()} onChange={onChange} />)
fireEvent.click(screen.getAllByTitle('Edit property')[1])
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } })
fireEvent.click(confirm('Save'))
expect(onChange.mock.calls[0][0][1]).toEqual({ key: 'B', value: '', icon: null, visible: true })
})
it('words the visibility toggle for the surface it edits', () => {
render(<PropertyList properties={props()} onChange={vi.fn()} visibleLabel="Show on canvas" />)
expect(screen.getAllByTitle('Hide on canvas')).toHaveLength(3)
@@ -15,7 +15,7 @@ interface ConfirmAddToGroupModalProps {
/** Label of the destination group/container. */
targetLabel: string
/** Destination kind — drives the wording. Defaults to 'group'. */
variant?: 'group' | 'container'
variant?: 'group' | 'container' | 'zone'
onConfirm: () => void
onCancel: () => void
}
@@ -28,8 +28,8 @@ export function ConfirmAddToGroupModal({
onConfirm,
onCancel,
}: ConfirmAddToGroupModalProps) {
const action = variant === 'container' ? 'Add to container' : 'Add to group'
const noun = variant === 'container' ? 'container' : 'group'
const noun = variant === 'container' ? 'container' : variant === 'zone' ? 'zone' : 'group'
const action = `Add to ${noun}`
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel() }}>
<DialogContent className="max-w-sm">
@@ -10,7 +10,7 @@ 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 { MIN_HANDLES, clampHandles, sideDefault } from '@/utils/handleUtils'
import { THEMES } from '@/utils/themes'
import { applyOpacity } from '@/utils/colorUtils'
import type {
@@ -26,7 +26,7 @@ import { MarkerShapePicker } from './MarkerShapePicker'
// ── Node types exposed for custom style, grouped by category (skip groupRect/group) ──
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'kvm', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
@@ -229,7 +229,7 @@ function NodeEditor({ nodeType, style, onChange, onApplyToExisting }: NodeEditor
<span className="text-xs text-[#8b949e] w-12">{label}</span>
<input
type="number"
min={sideDefault(side)}
min={MIN_HANDLES}
max={64}
step={1}
value={style[key] ?? sideDefault(side)}
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import {
Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network,
Server, Layers,
Search, RefreshCw, X, CheckCircle2, EyeOff, Trash2, Loader2, ServerCog,
} from 'lucide-react'
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
@@ -9,6 +9,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { toast } from 'sonner'
import { InventoryDeviceModal, type InventoryEntry } from '@/components/modals/InventoryDeviceModal'
import type { NodeType, ServiceInfo } from '@/types'
@@ -72,25 +73,20 @@ function serviceColor(port: number | null | undefined, category?: string | null)
return '#8b949e'
}
const TYPE_ICONS: Record<string, React.ElementType> = {
isp: Globe,
router: Router,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
switch: Network,
generic: Circle,
}
type SourceFilter = 'all' | SourceBucket
type StatusFilter = 'pending' | 'hidden'
const COMMON_PORTS = new Set([22, 80, 443])
/**
* What the device is. The curated `type` wins over the discovery guess: a
* device drawn on a canvas only ever has the former, and an edit that retypes a
* scanned row must not keep reading the scanner's opinion.
*/
function deviceType(d: InventoryEntry): NodeType | null {
return ((d.type ?? d.suggested_type) as NodeType) ?? null
}
function specialServiceName(d: InventoryEntry): string | undefined {
const candidates = (d.services ?? []).filter(
(s) => s.category != null && s.port != null && !COMMON_PORTS.has(s.port) && s.service_name,
@@ -101,7 +97,8 @@ function specialServiceName(d: InventoryEntry): string | undefined {
}
function deviceLabel(d: InventoryEntry): string {
return d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
// Same precedence as the detail modal — the name the user gave it first.
return d.label ?? d.friendly_name ?? d.hostname ?? specialServiceName(d) ?? d.ip ?? d.ieee_address ?? 'device'
}
// Pull the duplicate-conflict payload out of a 409 approve response, if that's
@@ -182,7 +179,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
const distinctTypes = useMemo(() => {
const set = new Set<string>()
devices.forEach((d) => { if (d.suggested_type) set.add(d.suggested_type) })
devices.forEach((d) => { const t = deviceType(d); if (t) set.add(t) })
return [...set].sort()
}, [devices])
@@ -190,7 +187,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
const q = search.trim().toLowerCase()
return devices.filter((d) => {
if (sourceFilter !== 'all' && !sourceBuckets(d).has(sourceFilter)) return false
if (typeFilter !== 'all' && d.suggested_type !== typeFilter) return false
if (typeFilter !== 'all' && deviceType(d) !== typeFilter) return false
// Inventory-only: optionally hide devices already placed on a canvas.
if (statusFilter === 'pending' && !showOnCanvas && (d.canvas_count ?? 0) > 0) return false
if (withServicesOnly && (d.services?.length ?? 0) === 0) return false
@@ -302,7 +299,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
return
}
const fallbackLabel = deviceLabel(device)
const type = (device.suggested_type ?? 'generic') as NodeType
const type = deviceType(device) ?? 'generic'
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
const properties = zwave
@@ -405,7 +402,7 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
approvedDevices.forEach((d, i) => {
const nodeId = deviceToNode[d.id]
if (!nodeId) return
const type = (d.suggested_type ?? 'generic') as NodeType
const type = deviceType(d) ?? 'generic'
const zwave = isZwaveType(type)
const wireless = isZigbeeType(type) || zwave
addNode({
@@ -594,6 +591,13 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
>
Rack devices
</button>
<button
onClick={() => setSourceFilter('canvas')}
className={`px-2.5 py-1.5 transition-colors border-l border-border ${sourceFilter === 'canvas' ? 'bg-[#8b949e]/20 text-foreground' : 'bg-[#0d1117] text-muted-foreground hover:text-foreground'}`}
title="Documented directly on a canvas — no scan ever saw it"
>
Canvas
</button>
</div>
<select
value={typeFilter}
@@ -798,6 +802,12 @@ export function DeviceInventoryModal({ open, onClose, highlightId, initialStatus
onApprove={handleApprove}
onHide={handleHide}
onIgnore={handleIgnore}
onSaved={(saved) => {
// Patch in place rather than refetch: the grid keeps its scroll
// position and the open detail modal keeps showing the same device.
setDevices((prev) => prev.map((d) => (d.id === saved.id ? saved : d)))
setSelected(saved)
}}
/>
</>
)
@@ -814,8 +824,8 @@ interface DeviceCardProps {
function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRef }: DeviceCardProps) {
const sources = orderedSources(device)
const roleType = (device.suggested_type ?? 'generic') as NodeType
const Icon = TYPE_ICONS[roleType] ?? Circle
const roleType = deviceType(device) ?? 'generic'
const Icon = NODE_TYPE_DEFAULT_ICONS[roleType] ?? NODE_TYPE_DEFAULT_ICONS.generic
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.
@@ -893,12 +903,12 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
{SOURCE_META[s].label}
</span>
))}
{device.suggested_type && (
{roleType !== 'generic' && (
<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}
{roleType}
</span>
)}
{device.lqi != null && (
@@ -1,49 +1,60 @@
import { Globe, Router, Server, Layers, Box, Container, HardDrive, Cpu, Wifi, Circle, Network } from 'lucide-react'
/**
* Device Inventory detail — show *and* edit.
*
* The inventory row owns what a device is; a canvas node only owns how it is
* drawn. So everything a user can curate on a node — properties, services,
* notes, check method — is editable here too, through the same editors the
* canvas uses (`PropertyList`, `ServiceModal`).
*
* Hardware specs are properties like any other: `cpu_*` / `ram_gb` / `disk_gb`
* still carry what Proxmox and the YAML import record, but nothing draws them,
* so they are not edited here — CPU / RAM / Disk are property suggestions.
*
* Layout mirrors `NodeModal`: one wide dialog, a header carrying identity and
* badges, then three columns — identity / operations / curation — so a device
* reads in one screen instead of a scrolling column of key-value pairs.
*/
import { useEffect, useState } from 'react'
import {
Check,
Copy,
Factory,
Fingerprint,
HeartPulse,
History,
Layers,
Network,
Pencil,
Plus,
StickyNote,
Tags,
X,
type LucideIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { isRackDevice } from '@/utils/deviceSources'
import type { NodeProperty } from '@/types'
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 { PropertyList } from '@/components/common/PropertyList'
import { ServiceModal } from './ServiceModal'
import { scanApi } from '@/api/client'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import { deviceFactsToNodeData } from '@/utils/deviceFacts'
import { resolveNodeColors } from '@/utils/nodeColors'
import { NODE_TYPE_DEFAULT_ICONS } from '@/utils/nodeIcons'
import { isRackDevice, orderedSources, SOURCE_META } from '@/utils/deviceSources'
import { DEVICE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
import { serviceToForm, type ServiceFormData, type ServiceSubmitData } from '@/utils/serviceForm'
import { NODE_TYPE_LABELS, type CheckMethod, type InventoryEntry, type NodeProperty, type NodeType, type ServiceInfo } from '@/types'
import modalStyles from './modal-interactive.module.css'
interface Service {
port: number
protocol: string
service_name: string
icon?: string | null
category?: string | null
}
export interface InventoryEntry {
id: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
services: Service[]
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
// Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
// Null/absent when the device is not on any canvas yet.
node_created_at?: string | null
node_last_scan?: string | null
node_last_modified?: string | null
node_last_seen?: string | null
}
// Home is `@/types` now — re-exported because most call sites import it here.
export type { InventoryEntry } from '@/types'
interface InventoryDeviceModalProps {
device: InventoryEntry | null
@@ -51,20 +62,8 @@ interface InventoryDeviceModalProps {
onApprove: (device: InventoryEntry) => void
onHide: (device: InventoryEntry) => void
onIgnore: (device: InventoryEntry) => void
}
const TYPE_ICONS: Record<string, React.ElementType> = {
isp: Globe,
router: Router,
server: Server,
proxmox: Layers,
vm: Box,
lxc: Container,
nas: HardDrive,
iot: Cpu,
ap: Wifi,
switch: Network,
generic: Circle,
/** Called with the saved row after an edit, so the list can refresh in place. */
onSaved?: (device: InventoryEntry) => void
}
const CATEGORY_COLORS: Record<string, string> = {
@@ -81,134 +80,690 @@ const CATEGORY_COLORS: Record<string, string> = {
iot: '#e3b341',
}
const CHECK_METHODS: CheckMethod[] = ['ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health', 'none']
const CHECK_METHOD_LABELS: Record<CheckMethod, string> = {
none: 'None',
ping: 'Ping',
http: 'HTTP',
https: 'HTTPS',
tcp: 'TCP',
ssh: 'SSH',
prometheus: 'Prometheus',
health: 'Health',
}
/** Live reachability — not the pending/approved/hidden lifecycle. */
const LIVE_META: Record<string, { color: string; label: string }> = {
online: { color: '#39d353', label: 'Online' },
offline: { color: '#f85149', label: 'Offline' },
pending: { color: '#e3b341', label: 'Checking' },
unknown: { color: '#8b949e', label: 'Unknown' },
}
const INPUT = 'bg-[#21262d] border-[#30363d] text-sm h-8'
function categoryColor(category: string | null | undefined) {
if (!category) return '#8b949e'
return CATEGORY_COLORS[category.toLowerCase()] ?? '#8b949e'
}
function InfoRow({ label, value }: { label: string; value: string }) {
/** A section card: heading bar with its icon, optional action, body. */
function Section({
title,
icon: Icon,
action,
children,
}: {
title: string
icon: LucideIcon
action?: React.ReactNode
children: React.ReactNode
}) {
return (
<div className="flex items-baseline gap-2 text-xs">
<span className="text-muted-foreground w-20 shrink-0">{label}</span>
<span className="font-mono text-foreground break-all">{value}</span>
<section className="rounded-lg border border-[#30363d] bg-[#161b22] overflow-hidden">
<header className="flex items-center justify-between gap-2 px-3 py-2 border-b border-[#30363d] bg-[#0d1117]/50">
<span className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
<Icon size={12} className="text-muted-foreground/70" aria-hidden />
{title}
</span>
{action}
</header>
<div className="p-3">{children}</div>
</section>
)
}
function Chip({ color, children, title }: { color: string; children: React.ReactNode; title?: string }) {
return (
<span
title={title}
className="text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${color}22`, color }}
>
{children}
</span>
)
}
/** Copies a technical value; the row keeps its own confirmation state. */
function CopyButton({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false)
const copy = () => {
// `clipboard` is absent outside a secure context (and in jsdom), so the
// promise itself is optional, not just the API.
const written = navigator.clipboard?.writeText(value)
if (!written) return
written
.then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1200)
})
.catch(() => toast.error('Could not copy'))
}
return (
<button
onClick={copy}
aria-label={`Copy ${label}`}
className="ml-auto shrink-0 text-muted-foreground/50 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-[#00d4ff] transition-opacity cursor-pointer"
>
{copied ? <Check size={11} className="text-[#39d353]" /> : <Copy size={11} />}
</button>
)
}
function InfoRow({ label, value, copyable }: { label: string; value: string; copyable?: boolean }) {
return (
<div className="group flex items-baseline gap-3 py-1 border-b border-[#30363d]/40 last:border-b-0 text-xs">
<span className="w-20 shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="min-w-0 font-mono text-foreground break-all">{value}</span>
{copyable && <CopyButton label={label} value={value} />}
</div>
)
}
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore }: InventoryDeviceModalProps) {
/** A timestamp reads relative, with the exact value on hover. */
function TimeRow({ label, iso }: { label: string; iso: string }) {
return (
<div className="flex items-baseline gap-3 py-1 border-b border-[#30363d]/40 last:border-b-0 text-xs" title={formatTimestamp(iso)}>
<span className="w-20 shrink-0 text-[11px] text-muted-foreground">{label}</span>
<span className="font-mono text-foreground/80">{formatRelative(iso)}</span>
</div>
)
}
function Empty({ children }: { children: React.ReactNode }) {
return <p className="text-xs text-muted-foreground italic">{children}</p>
}
function Field({ label, hint, children, className = '' }: { label: string; hint?: string; children: React.ReactNode; className?: string }) {
return (
<div className={`flex flex-col gap-1.5 min-w-0 ${className}`}>
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
{hint && <span className="text-[10px] text-muted-foreground/50">{hint}</span>}
</div>
)
}
/** The editable subset of an inventory row, as strings for the form inputs. */
interface EditForm {
label: string
type: string
hostname: string
ip: string
mac: string
os: string
vendor: string
model: string
friendly_name: string
device_subtype: string
notes: string
cpu_count: string
cpu_model: string
ram_gb: string
disk_gb: string
show_hardware: boolean
check_method: string
check_target: string
}
function toForm(d: InventoryEntry): EditForm {
return {
label: d.label ?? '',
// Falls back to the discovery guess, so a first edit adopts it rather than
// silently clearing what the scanner suggested.
type: d.type ?? d.suggested_type ?? '',
hostname: d.hostname ?? '',
ip: d.ip ?? '',
mac: d.mac ?? '',
os: d.os ?? '',
vendor: d.vendor ?? '',
model: d.model ?? '',
friendly_name: d.friendly_name ?? '',
device_subtype: d.device_subtype ?? '',
notes: d.notes ?? '',
cpu_count: d.cpu_count != null ? String(d.cpu_count) : '',
cpu_model: d.cpu_model ?? '',
ram_gb: d.ram_gb != null ? String(d.ram_gb) : '',
disk_gb: d.disk_gb != null ? String(d.disk_gb) : '',
show_hardware: d.show_hardware ?? false,
check_method: d.check_method ?? '',
check_target: d.check_target ?? '',
}
}
const nullable = (v: string) => (v.trim() === '' ? null : v.trim())
const numeric = (v: string) => (v.trim() === '' ? null : Number(v))
export function InventoryDeviceModal({ device, onClose, onApprove, onHide, onIgnore, onSaved }: InventoryDeviceModalProps) {
const [editing, setEditing] = useState(false)
const [form, setForm] = useState<EditForm>(() => (device ? toForm(device) : toForm({} as InventoryEntry)))
const [properties, setProperties] = useState<NodeProperty[]>(device?.properties ?? [])
const [services, setServices] = useState<ServiceInfo[]>(device?.services ?? [])
const [svcModal, setSvcModal] = useState<{ index: number | null; form?: ServiceFormData } | null>(null)
const [saving, setSaving] = useState(false)
const activeTheme = useThemeStore((s) => s.activeTheme)
// Reset the form whenever the modal is pointed at another device — the
// component stays mounted across card clicks in the inventory grid.
useEffect(() => {
if (!device) return
setForm(toForm(device))
setProperties(device.properties ?? [])
setServices(device.services ?? [])
setEditing(false)
setSvcModal(null)
}, [device])
if (!device) return null
const TypeIcon = TYPE_ICONS[device.suggested_type ?? 'generic'] ?? Circle
const resolvedType = (device.type ?? device.suggested_type ?? 'generic') as NodeType
const TypeIcon = NODE_TYPE_DEFAULT_ICONS[resolvedType] ?? NODE_TYPE_DEFAULT_ICONS.generic
// Same accent the node wears on the canvas, so a device looks like itself.
const roleColor = resolveNodeColors({ type: resolvedType, custom_colors: undefined }, activeTheme).border
const isZigbee = device.discovery_source === 'zigbee'
const rackOnly = isRackDevice(device)
const titleLabel = device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const sources = orderedSources(device)
const live = LIVE_META[device.status_live ?? 'unknown'] ?? LIVE_META.unknown
const titleLabel = device.label ?? device.friendly_name ?? device.hostname ?? device.ip ?? device.ieee_address ?? 'Pending device'
const set = <K extends keyof EditForm>(key: K, value: EditForm[K]) => setForm((f) => ({ ...f, [key]: value }))
const handleApprove = () => { onApprove(device) }
const handleHide = () => { onHide(device); onClose() }
const handleIgnore = () => { onIgnore(device); onClose() }
const handleCancel = () => {
setEditing(false)
setForm(toForm(device))
setProperties(device.properties ?? [])
setServices(device.services ?? [])
}
const handleSubmitService = (data: ServiceSubmitData) => {
const svc: ServiceInfo = {
...(data.port != null ? { port: data.port } : {}),
protocol: data.protocol,
service_name: data.service_name,
...(data.path ? { path: data.path } : {}),
...(data.host ? { host: data.host } : {}),
...(data.icon ? { icon: data.icon } : {}),
}
setServices((prev) =>
svcModal?.index == null ? [...prev, svc] : prev.map((s, i) => (i === svcModal.index ? { ...s, ...svc } : s))
)
}
const handleSave = async () => {
setSaving(true)
try {
const res = await scanApi.updatePending(device.id, {
label: nullable(form.label),
type: nullable(form.type),
hostname: nullable(form.hostname),
ip: nullable(form.ip),
mac: nullable(form.mac),
os: nullable(form.os),
vendor: nullable(form.vendor),
model: nullable(form.model),
friendly_name: nullable(form.friendly_name),
device_subtype: nullable(form.device_subtype),
notes: nullable(form.notes),
// Hardware is curated as properties, like every other spec — these five
// are no longer editable here. They still carry Proxmox and YAML data,
// so they go back untouched rather than being silently cleared.
cpu_count: numeric(form.cpu_count),
cpu_model: nullable(form.cpu_model),
ram_gb: numeric(form.ram_gb),
disk_gb: numeric(form.disk_gb),
show_hardware: form.show_hardware,
check_method: nullable(form.check_method) as CheckMethod | null,
check_target: nullable(form.check_target),
properties,
services,
})
// Push the row straight into the canvas on screen. Its nodes carry a copy
// of these facts, loaded before this edit — left stale, the next canvas
// save (even one for a moved node) would report them as an edit and roll
// the row back.
useCanvasStore.getState().applyDeviceFacts(device.id, deviceFactsToNodeData(res.data))
toast.success('Device updated')
setEditing(false)
onSaved?.(res.data)
} catch {
toast.error('Could not update device')
} finally {
setSaving(false)
}
}
const timestamps: { label: string; iso: string }[] = []
if (device.discovered_at) timestamps.push({ label: 'Discovered', iso: device.discovered_at })
if (device.node_created_at) timestamps.push({ label: 'On canvas', iso: device.node_created_at })
if (device.node_last_scan) timestamps.push({ label: 'Last scan', iso: device.node_last_scan })
if (device.node_last_seen) timestamps.push({ label: 'Last seen', iso: device.node_last_seen })
if (device.node_last_modified) timestamps.push({ label: 'Modified', iso: device.node_last_modified })
return (
<Dialog open={!!device} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
<TypeIcon size={15} className="text-[#00d4ff] shrink-0" />
{titleLabel}
{isZigbee && (
<span className="ml-1 text-[9px] font-mono uppercase px-1 py-0.5 rounded bg-[#00d4ff]/15 text-[#00d4ff] border border-[#00d4ff]/30">
Zigbee
</span>
)}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
{/* Device info */}
<div className="flex flex-col gap-1.5 p-3 rounded-md bg-[#21262d] border border-[#30363d]">
{device.ip && <InfoRow label="IP" value={device.ip} />}
{device.hostname && <InfoRow label="Hostname" value={device.hostname} />}
{device.mac && <InfoRow label="MAC" value={device.mac} />}
{device.os && <InfoRow label="OS" value={device.os} />}
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} />}
{device.friendly_name && device.friendly_name !== device.hostname && (
<InfoRow label="Name" value={device.friendly_name} />
)}
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
{device.model && <InfoRow label="Model" value={device.model} />}
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
{device.suggested_type && (
<InfoRow label="Type" value={device.suggested_type} />
)}
{device.discovery_source && (
<InfoRow label="Source" value={device.discovery_source.toUpperCase()} />
)}
<InfoRow label="Discovered" value={new Date(device.discovered_at.endsWith('Z') ? device.discovered_at : device.discovered_at + 'Z').toLocaleString()} />
</div>
{/* Services (skipped for Zigbee devices — they don't have IP services) */}
{!isZigbee && <div>
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
Services found ({device.services.length})
</p>
{device.services.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No services detected</p>
) : (
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{device.services.map((svc, i) => (
<div
key={i}
className="flex items-center gap-2 px-2.5 py-1.5 rounded bg-[#21262d] border border-[#30363d] text-xs"
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: categoryColor(svc.category) }}
/>
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port}</span>
<span className="text-muted-foreground w-7 shrink-0">{svc.protocol}</span>
<span className="text-foreground truncate">{svc.service_name}</span>
{svc.category && (
<span className="ml-auto text-[10px] shrink-0" style={{ color: categoryColor(svc.category) }}>
{svc.category}
</span>
)}
</div>
<DialogContent className="!max-w-none w-[min(1180px,95vw)] max-h-[90vh] p-0 gap-0 flex flex-col overflow-hidden bg-[#0d1117] border-[#30363d] text-foreground">
{/* ── Header: who this device is, at a glance ── */}
<DialogHeader className="shrink-0 px-5 py-4 border-b border-[#30363d] bg-[#161b22]">
<div className="flex items-start gap-3 pr-10">
<div
className="w-11 h-11 shrink-0 rounded-lg border flex items-center justify-center"
style={{ background: `${roleColor}18`, borderColor: `${roleColor}55`, color: roleColor }}
>
<TypeIcon size={20} />
</div>
<div className="min-w-0 flex-1">
<DialogTitle className="text-base font-semibold leading-tight break-all">{titleLabel}</DialogTitle>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{sources.map((s) => (
<Chip key={s} color={SOURCE_META[s].color}>{SOURCE_META[s].label}</Chip>
))}
{(device.type ?? device.suggested_type) && (
<Chip color={roleColor}>{NODE_TYPE_LABELS[resolvedType] ?? resolvedType}</Chip>
)}
<span
className="flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider"
style={{ background: `${live.color}22`, color: live.color }}
title="Live reachability"
>
<span className="w-1.5 h-1.5 rounded-full" style={{ background: live.color }} />
{live.label}
</span>
{(device.canvas_count ?? 0) > 0 && (
<span
className="flex items-center gap-1 text-[9px] font-mono px-1.5 py-0.5 rounded uppercase tracking-wider bg-[#00d4ff]/15 text-[#00d4ff]"
title={`Drawn on ${device.canvas_count} canvas${device.canvas_count !== 1 ? 'es' : ''}`}
>
<Layers size={10} />
{device.canvas_count}
</span>
)}
{device.status === 'hidden' && <Chip color="#8b949e">Hidden</Chip>}
{device.lqi != null && <Chip color="#8b949e">LQI {device.lqi}</Chip>}
</div>
)}
</div>}
{/* Actions */}
<div className="flex gap-2 pt-1">
{/* Rack gear is mounted from a rack canvas, never approved onto a
logical one — so it gets no Approve button at all. */}
{!rackOnly && (
</div>
{!editing && (
<Button
size="sm"
className="flex-1 bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
onClick={handleApprove}
variant="ghost"
className={`shrink-0 gap-1.5 text-[#00d4ff] hover:text-[#00d4ff] hover:bg-[#00d4ff]/10 ${modalStyles['modal-interactive']}`}
onClick={() => setEditing(true)}
>
Approve
<Pencil size={13} /> Edit
</Button>
)}
<Button
size="sm"
variant="ghost"
className="flex-1 text-muted-foreground hover:text-foreground hover:bg-[#30363d]"
onClick={handleHide}
>
Hide
</Button>
<Button
size="sm"
variant="ghost"
className="flex-1 text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
onClick={handleIgnore}
>
Delete
</Button>
</div>
</DialogHeader>
{/* ── Body: three columns — identity / operations / curation ── */}
<div className="flex-1 min-h-0 overflow-y-auto p-5">
{editing ? (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
<div className="flex flex-col gap-4 min-w-0">
<Section title="Identity" icon={Fingerprint}>
<div className="grid grid-cols-2 gap-3">
<Field label="Name" className="col-span-2">
<Input value={form.label} onChange={(e) => set('label', e.target.value)} placeholder="Display name" className={INPUT} />
</Field>
<Field label="Type" className="col-span-2">
<Select value={form.type || undefined} onValueChange={(v) => set('type', (v as string | null) ?? '')}>
<SelectTrigger className={`${INPUT} w-full cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Device type selector">
<SelectValue placeholder="Pick a type" />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{DEVICE_TYPE_GROUPS.map((group, i) => (
<SelectGroup key={group.label}>
{i > 0 && <SelectSeparator className="bg-[#30363d]" />}
<SelectLabel className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50 px-2 py-1">
{group.label}
</SelectLabel>
{group.types.map((t) => (
<SelectItem key={t} value={t} className="text-sm pl-4">
{NODE_TYPE_LABELS[t] ?? t}
</SelectItem>
))}
</SelectGroup>
))}
</SelectContent>
</Select>
</Field>
<Field label="Hostname">
<Input value={form.hostname} onChange={(e) => set('hostname', e.target.value)} placeholder="server.lan" className={`${INPUT} font-mono`} />
</Field>
<Field label="IP" hint="comma-separated">
<Input value={form.ip} onChange={(e) => set('ip', e.target.value)} placeholder="10.0.0.5, 10.0.1.5" className={`${INPUT} font-mono`} />
</Field>
<Field label="MAC">
<Input value={form.mac} onChange={(e) => set('mac', e.target.value)} placeholder="aa:bb:cc:dd:ee:ff" className={`${INPUT} font-mono`} />
</Field>
<Field label="OS">
<Input value={form.os} onChange={(e) => set('os', e.target.value)} placeholder="Debian 12" className={INPUT} />
</Field>
</div>
</Section>
<Section title="Make & model" icon={Factory}>
<div className="grid grid-cols-2 gap-3">
<Field label="Vendor">
<Input value={form.vendor} onChange={(e) => set('vendor', e.target.value)} className={INPUT} />
</Field>
<Field label="Model">
<Input value={form.model} onChange={(e) => set('model', e.target.value)} className={INPUT} />
</Field>
<Field label="Friendly name">
<Input value={form.friendly_name} onChange={(e) => set('friendly_name', e.target.value)} className={INPUT} />
</Field>
<Field label="Role">
<Input value={form.device_subtype} onChange={(e) => set('device_subtype', e.target.value)} placeholder="e.g. router" className={INPUT} />
</Field>
</div>
</Section>
</div>
<div className="flex flex-col gap-4 min-w-0">
<Section title="Monitoring" icon={HeartPulse}>
<div className="grid grid-cols-2 gap-3">
<Field label="Check method">
<Select value={form.check_method || undefined} onValueChange={(v) => set('check_method', (v as string | null) ?? '')}>
<SelectTrigger className={`${INPUT} w-full cursor-pointer ${modalStyles['modal-interactive']}`} aria-label="Check method selector">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm">{CHECK_METHOD_LABELS[m]}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Check target">
<Input value={form.check_target} onChange={(e) => set('check_target', e.target.value)} placeholder="host:port or URL" className={`${INPUT} font-mono`} />
</Field>
</div>
</Section>
<Section title="Notes" icon={StickyNote}>
<Textarea
value={form.notes}
onChange={(e) => set('notes', e.target.value)}
rows={6}
placeholder="Anything worth remembering about this device."
className="bg-[#21262d] border-[#30363d] text-sm"
/>
</Section>
</div>
<div className="flex flex-col gap-4 min-w-0">
{/* Same editors as the canvas — one implementation, one behaviour. */}
<section className="rounded-lg border border-[#30363d] bg-[#161b22] overflow-hidden">
<PropertyList
properties={properties}
onChange={setProperties}
visibleLabel="Show on node"
// Hardware is a property like any other; these are the keys
// the Proxmox import and the YAML import already mint.
suggestions={['CPU Model', 'CPU Cores', 'RAM', 'Disk']}
/>
</section>
<Section
title={`Services${services.length > 0 ? ` (${services.length})` : ''}`}
icon={Network}
action={
<button
onClick={() => setSvcModal({ index: null })}
className="flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Plus size={10} /> Add
</button>
}
>
{services.length === 0 ? (
<Empty>No services — click Add to register one.</Empty>
) : (
<div className="flex flex-col gap-1.5">
{services.map((svc, i) => (
<div key={`${svc.port ?? 'host'}-${svc.protocol}-${i}`} className="group flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs">
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port ?? '—'}</span>
<span className="text-muted-foreground w-8 shrink-0">{svc.protocol}</span>
<span className="text-foreground truncate">{svc.service_name}</span>
<div className="ml-auto flex items-center gap-1.5 shrink-0">
<button
onClick={() => setSvcModal({ index: i, form: serviceToForm(svc) })}
title="Edit service"
className="text-[#8b949e] hover:text-[#00d4ff] cursor-pointer"
>
<Pencil size={11} />
</button>
<button
onClick={() => setServices((prev) => prev.filter((_, j) => j !== i))}
title="Remove service"
className="text-[#8b949e] hover:text-[#f85149] cursor-pointer"
>
<X size={11} />
</button>
</div>
</div>
))}
</div>
)}
</Section>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
{/* What the device is: how to reach it, and what answers there. */}
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-identity">
<Section title="Identity" icon={Fingerprint}>
<div className="flex flex-col">
{device.ip && <InfoRow label="IP" value={device.ip} copyable />}
{device.hostname && <InfoRow label="Hostname" value={device.hostname} copyable />}
{device.mac && <InfoRow label="MAC" value={device.mac} copyable />}
{device.ieee_address && <InfoRow label="IEEE" value={device.ieee_address} copyable />}
{device.os && <InfoRow label="OS" value={device.os} />}
{(device.type ?? device.suggested_type) && (
<InfoRow label="Type" value={(device.type ?? device.suggested_type) as string} />
)}
{device.friendly_name && device.friendly_name !== device.hostname && (
<InfoRow label="Name" value={device.friendly_name} />
)}
{device.vendor && <InfoRow label="Vendor" value={device.vendor} />}
{device.model && <InfoRow label="Model" value={device.model} />}
{device.device_subtype && <InfoRow label="Role" value={device.device_subtype} />}
{device.lqi != null && <InfoRow label="LQI" value={String(device.lqi)} />}
{!device.ip && !device.hostname && !device.mac && !device.ieee_address && (
<Empty>Nothing identifying recorded yet.</Empty>
)}
</div>
</Section>
{/* Zigbee devices have no IP services — the section would always be empty. */}
{!isZigbee && (
<Section title={`Services found (${device.services.length})`} icon={Network}>
{device.services.length === 0 ? (
<Empty>No services detected</Empty>
) : (
<div className="flex flex-col gap-1.5 max-h-72 overflow-y-auto">
{device.services.map((svc, i) => (
<div
key={i}
className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs"
>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: categoryColor(svc.category) }}
/>
<span className="font-mono text-[#00d4ff] w-10 shrink-0">{svc.port}</span>
<span className="text-muted-foreground w-8 shrink-0">{svc.protocol}</span>
<span className="text-foreground truncate">{svc.service_name}</span>
{svc.category && (
<span className="ml-auto text-[10px] shrink-0" style={{ color: categoryColor(svc.category) }}>
{svc.category}
</span>
)}
</div>
))}
</div>
)}
</Section>
)}
</div>
{/* How it is watched, and what happened to it. */}
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-operations">
<Section title="Monitoring" icon={HeartPulse}>
<div className="flex flex-col">
{device.check_method ? (
<InfoRow
label="Check"
value={device.check_target ? `${device.check_method} → ${device.check_target}` : device.check_method}
/>
) : (
<Empty>Not monitored — pick a check method from Edit.</Empty>
)}
{device.response_time_ms != null && <InfoRow label="Response" value={`${device.response_time_ms} ms`} />}
{device.discovery_source && <InfoRow label="Source" value={device.discovery_source.toUpperCase()} />}
{device.canvas_count != null && device.canvas_count > 0 && (
<InfoRow label="Canvases" value={String(device.canvas_count)} />
)}
</div>
</Section>
<Section title="Activity" icon={History}>
<div className="flex flex-col">
{timestamps.length === 0 ? (
<Empty>No activity recorded.</Empty>
) : (
timestamps.map((t) => <TimeRow key={t.label} label={t.label} iso={t.iso} />)
)}
</div>
</Section>
<Section title="Notes" icon={StickyNote}>
{device.notes ? (
<p className="text-xs text-foreground/80 whitespace-pre-wrap">{device.notes}</p>
) : (
<Empty>No notes.</Empty>
)}
</Section>
</div>
{/* What the user curated. */}
<div className="flex flex-col gap-4 min-w-0" data-testid="device-column-curation">
<Section
title={`Properties${device.properties && device.properties.length > 0 ? ` (${device.properties.length})` : ''}`}
icon={Tags}
>
{device.properties && device.properties.length > 0 ? (
<div className="flex flex-col gap-1.5">
{device.properties.map((prop, i) => (
<div key={`${prop.key}-${i}`} className="flex items-baseline gap-2 px-2.5 py-1.5 rounded-md bg-[#21262d] border border-[#30363d] text-xs">
<span className="text-muted-foreground shrink-0">{prop.key}</span>
<span className="ml-auto font-mono text-foreground break-all text-right">{prop.value}</span>
</div>
))}
</div>
) : (
<Empty>No properties.</Empty>
)}
</Section>
</div>
</div>
)}
</div>
{/* ── Footer: lifecycle in view, form controls in edit ── */}
<div className="shrink-0 flex items-center justify-between gap-3 px-5 py-3 border-t border-[#30363d] bg-[#161b22]">
<p className="text-[11px] text-muted-foreground/70 min-w-0 truncate">
{editing
? 'These facts belong to the device — every canvas drawing it follows.'
: rackOnly
? 'Rack gear — mounted from a rack canvas, never placed on a logical one.'
: 'One device, one row. Editing here updates every canvas drawing it.'}
</p>
{editing ? (
<div className="flex items-center gap-2 shrink-0">
<Button size="sm" variant="ghost" onClick={handleCancel} className="text-muted-foreground hover:text-foreground">
Cancel
</Button>
<Button
size="sm"
className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 min-w-20"
onClick={handleSave}
disabled={saving}
>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
) : (
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground hover:bg-[#30363d]"
onClick={handleHide}
>
Hide
</Button>
<Button
size="sm"
variant="ghost"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
onClick={handleIgnore}
>
Delete
</Button>
{/* Rack gear is mounted from a rack canvas, never approved onto a
logical one — so it gets no Approve button at all. */}
{!rackOnly && (
<Button
size="sm"
className="bg-[#39d353]/15 text-[#39d353] hover:bg-[#39d353]/25 border border-[#39d353]/30"
onClick={handleApprove}
>
Approve
</Button>
)}
</div>
)}
</div>
{svcModal && (
<ServiceModal
key={`svc-${svcModal.index ?? 'new'}`}
open
onClose={() => setSvcModal(null)}
onSubmit={handleSubmitService}
initial={svcModal.form}
title={svcModal.index === null ? 'Add Service' : 'Edit Service'}
confirmLabel={svcModal.index === null ? 'Add' : 'Save'}
/>
)}
</DialogContent>
</Dialog>
)
+11 -26
View File
@@ -12,19 +12,9 @@ import { useThemeStore } from '@/stores/themeStore'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, NODE_TYPE_DEFAULT_ICONS, isBrandIconKey, brandIconSlug, brandIconUrl } from '@/utils/nodeIcons'
import { IconPickerPanel } from './IconPickerPanel'
import { MAX_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { getValidParentTypes } from '@/utils/virtualEdgeParent'
const NODE_TYPE_GROUPS: { label: string; types: NodeType[] }[] = [
{ label: 'Hardware', types: ['isp', 'router', 'firewall', 'switch', 'server', 'nas', 'ap', 'printer'] },
{ label: 'Virtualization', types: ['proxmox', 'vm', 'lxc', 'docker_host', 'docker_container'] },
{ label: 'IoT', types: ['iot', 'camera', 'cpl'] },
{ label: 'Zigbee', types: ['zigbee_coordinator', 'zigbee_router', 'zigbee_enddevice'] },
{ label: 'Z-Wave', types: ['zwave_coordinator', 'zwave_router', 'zwave_enddevice'] },
{ label: 'Personal', types: ['computer', 'laptop', 'mobile'] },
{ label: 'Electrical', types: ['grid', 'ups', 'battery', 'generator', 'solar_panel', 'inverter', 'circuit_breaker', 'contactor', 'electrical_switch', 'socket', 'light', 'meter', 'transformer', 'load'] },
{ label: 'Generic', types: ['generic', 'groupRect'] },
]
import { MAX_HANDLES, MIN_HANDLES, clampHandles, sideDefault, handleCountField, type Side } from '@/utils/handleUtils'
import { isValidParentNode } from '@/utils/virtualEdgeParent'
import { NODE_TYPE_GROUPS } from '@/utils/nodeTypeGroups'
// Maps a side to its per-type default field on NodeTypeStyle.
const SIDE_STYLE_KEY: Record<Side, keyof NodeTypeStyle> = {
@@ -44,7 +34,7 @@ function CPStepper({ label, side, value, onChange }: {
value: number
onChange: (v: number) => void
}) {
const min = sideDefault(side)
const min = MIN_HANDLES
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'
@@ -174,15 +164,10 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
setLabelError(false)
const selectedType = (form.type ?? 'generic') as NodeType
const canUseContainerMode = CONTAINER_MODE_TYPES.includes(selectedType)
const validParentTypes = getValidParentTypes(selectedType)
// A parent is valid either by the type rules (lxc/vm/docker_container) or
// because the candidate is a container-mode node (any child can nest in it).
const isValidParent = (p: ParentCandidate) =>
validParentTypes.includes(p.type) || p.container_mode === true
let safeParentId = form.parent_id
if (safeParentId) {
const parent = parentCandidates.find((n) => n.id === safeParentId)
if (!parent || !isValidParent(parent)) safeParentId = undefined
if (!parent || !isValidParentNode(selectedType, parent)) safeParentId = undefined
}
const isGroupType = selectedType === 'groupRect' || selectedType === 'group'
onSubmit({
@@ -225,7 +210,7 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
// Drop the parent only if it's no longer a valid target for the
// new type — keep container-mode parents (any node can nest).
const parent = parentCandidates.find((n) => n.id === f.parent_id)
if (f.parent_id && !(parent && (getValidParentTypes(t).includes(parent.type) || parent.container_mode === true))) {
if (f.parent_id && !(parent && isValidParentNode(t, parent))) {
next.parent_id = undefined
}
return next
@@ -377,13 +362,13 @@ export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node'
{/* Parent Container */}
{(() => {
const childType = (form.type ?? 'generic') as NodeType
const validParentTypes = getValidParentTypes(childType)
// Candidates: type-based parents (lxc/vm/docker_container) plus any
// container-mode node. The current parent is always kept so an
// already-nested node can be re-targeted or detached here.
// Candidates: type-based parents (lxc/vm/docker_container), any
// container-mode node, and visual groups. The current parent is
// always kept so an already-nested node can be re-targeted or
// detached here.
const validParents = parentCandidates.filter(
(n) => n.id !== currentNodeId &&
(validParentTypes.includes(n.type) || n.container_mode === true || n.id === form.parent_id),
(isValidParentNode(childType, n) || n.id === form.parent_id),
)
if (validParents.length === 0) return null
return (
@@ -45,11 +45,13 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se
const port = trimmedPort === '' ? undefined : Number.parseInt(trimmedPort, 10)
if (port != null && (Number.isNaN(port) || port < 1 || port > 65535)) return
const path = form.path.trim()
const hostOverride = form.host.trim()
onSubmit({
service_name: name,
protocol: form.protocol,
port,
path: path || undefined,
host: hostOverride || undefined,
icon: form.icon,
})
onClose()
@@ -134,6 +136,20 @@ export function ServiceModal({ open, onClose, onSubmit, initial, title = 'Add Se
</div>
</div>
{/* Host override */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Host</Label>
<Input
value={form.host}
onChange={(e) => set('host', e.target.value)}
placeholder="Node host (app.example.com)"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
<span className="text-[10px] text-muted-foreground/60">
Overrides the node host for this service only.
</span>
</div>
{/* Path */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Path</Label>
@@ -693,3 +693,70 @@ describe('DeviceInventoryModal', () => {
})
})
})
/**
* A device documented straight on a canvas carries a curated `label` and `type`
* and no discovery guess at all — the card has to read those, or a printer
* shows up unnamed with the generic circle.
*/
describe('DeviceInventoryModal — the curated type wins over the discovery guess', () => {
const DEVICE_CANVAS = {
id: 'dev-printer',
ip: '192.168.1.77',
hostname: null,
mac: null,
os: null,
services: [],
label: 'Office printer',
type: 'printer',
suggested_type: null,
status: 'approved',
discovery_source: 'canvas',
discovery_sources: ['canvas'],
discovered_at: '2026-02-01T00:00:00Z',
}
it('names the card from the curated label', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
expect(screen.getByText('Office printer')).toBeInTheDocument()
})
it('badges the curated type', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
expect(within(screen.getByTestId('pending-card-dev-printer')).getByText('printer')).toBeInTheDocument()
})
it('prefers it over a stale discovery guess', async () => {
mockPending.mockResolvedValue({ data: [{ ...DEVICE_CANVAS, suggested_type: 'server' }] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
const card = within(screen.getByTestId('pending-card-dev-printer'))
expect(card.getByText('printer')).toBeInTheDocument()
expect(card.queryByText('server')).toBeNull()
})
it('filters on it', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS, DEVICE_IP] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
fireEvent.change(screen.getByLabelText('Type filter'), { target: { value: 'printer' } })
expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument()
expect(screen.queryByTestId('pending-card-dev-a')).not.toBeInTheDocument()
})
it('approves onto a node of that type', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_CANVAS] })
render(<DeviceInventoryModal {...baseProps} />)
await waitFor(() => expect(screen.getByTestId('pending-card-dev-printer')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('pending-card-dev-printer'))
fireEvent.click(screen.getByTestId('do-approve'))
await waitFor(() => expect(mockApprove).toHaveBeenCalled())
expect(mockApprove.mock.calls[0][1]).toMatchObject({ type: 'printer', label: 'Office printer' })
})
})
@@ -0,0 +1,194 @@
/**
* Edit mode of the device detail modal — the write half of "the inventory row
* owns the device facts".
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { InventoryDeviceModal } from '../InventoryDeviceModal'
import type { InventoryEntry } from '@/types'
const mockUpdatePending = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: {
updatePending: (...a: unknown[]) => mockUpdatePending(...a),
},
}))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
return {
id: 'dev-1',
ip: '192.168.1.100',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pve.local',
os: 'Linux',
services: [],
suggested_type: 'server',
status: 'pending',
discovered_at: '2024-01-15T10:30:00Z',
...overrides,
}
}
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
beforeEach(() => {
vi.clearAllMocks()
mockUpdatePending.mockResolvedValue({ data: makeDevice({ label: 'Saved' }) })
})
describe('InventoryDeviceModal — view mode extras', () => {
it('shows the curated fields the inventory row now owns', () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({
notes: 'garage rack',
cpu_count: 4,
ram_gb: 16,
check_method: 'http',
check_target: 'http://192.168.1.100',
properties: [{ key: 'Owner', value: 'me', icon: null, visible: true }],
})}
/>
)
expect(screen.getByText('garage rack')).toBeInTheDocument()
expect(screen.getByText('http → http://192.168.1.100')).toBeInTheDocument()
expect(screen.getByText('Owner')).toBeInTheDocument()
expect(screen.getByText('me')).toBeInTheDocument()
})
it('prefers the curated label and type over the discovery guesses', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ label: 'Big NAS', type: 'nas' })} />)
expect(screen.getAllByText('Big NAS').length).toBeGreaterThan(0)
expect(screen.getByText('nas')).toBeInTheDocument()
expect(screen.queryByText('server')).toBeNull()
})
})
describe('InventoryDeviceModal — edit mode', () => {
it('switches to the form on Edit and back on Cancel', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
// Lifecycle buttons belong to the view; the form has Save / Cancel.
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument()
})
it('seeds the form from the row, falling back to the discovery type', async () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ label: null, type: null })} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect((screen.getByDisplayValue('pve.local') as HTMLInputElement).value).toBe('pve.local')
expect(screen.getByDisplayValue('192.168.1.100')).toBeInTheDocument()
// The untouched type select adopts `suggested_type` rather than clearing it.
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
expect(mockUpdatePending.mock.calls[0][1].type).toBe('server')
})
it('PATCHes the edited fields and reports the saved row', async () => {
const onSaved = vi.fn()
render(<InventoryDeviceModal {...noop} onSaved={onSaved} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByPlaceholderText('Display name'), { target: { value: 'Big NAS' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
const [id, body] = mockUpdatePending.mock.calls[0]
expect(id).toBe('dev-1')
expect(body.label).toBe('Big NAS')
expect(body.hostname).toBe('pve.local')
expect(body.properties).toEqual([])
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({ label: 'Saved' })))
})
it('sends null rather than an empty string for a cleared field', async () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByDisplayValue('192.168.1.100'), { target: { value: '' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
expect(mockUpdatePending.mock.calls[0][1].ip).toBeNull()
})
// Hardware moved to the properties; the columns are no longer edited here but
// must still be sent as numbers, not as the form's strings.
it('sends the numeric hardware fields as numbers', async () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ cpu_count: 2, ram_gb: 8 })} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
const body = mockUpdatePending.mock.calls[0][1]
expect(body.ram_gb).toBe(8)
expect(body.cpu_count).toBe(2)
})
it('edits properties through the shared PropertyList', async () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({ properties: [{ key: 'Rack', value: 'A1', icon: null, visible: true }] })}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
// The shared editor renders the existing property with its remove control.
expect(screen.getByText('Rack')).toBeInTheDocument()
fireEvent.click(screen.getByTitle('Remove property'))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
expect(mockUpdatePending.mock.calls[0][1].properties).toEqual([])
})
it('removes a service from the editable list', async () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({
services: [
{ port: 80, protocol: 'tcp', service_name: 'HTTP' },
{ port: 443, protocol: 'tcp', service_name: 'HTTPS' },
],
})}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.click(screen.getAllByTitle('Remove service')[0])
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
expect(mockUpdatePending.mock.calls[0][1].services).toEqual([
{ port: 443, protocol: 'tcp', service_name: 'HTTPS' },
])
})
it('stays in edit mode when the save fails', async () => {
mockUpdatePending.mockRejectedValueOnce(new Error('boom'))
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
})
it('reseeds the form when pointed at another device', () => {
const { rerender } = render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByDisplayValue('pve.local')).toBeInTheDocument()
rerender(<InventoryDeviceModal {...noop} device={makeDevice({ id: 'dev-2', hostname: 'other.local' })} />)
// Back to the view of the new device, not the previous one's open form.
expect(screen.queryByRole('button', { name: 'Save' })).toBeNull()
expect(screen.getAllByText('other.local').length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,217 @@
/**
* The detail modal reads as a device sheet: a header that identifies the row at
* a glance, sections that stay put when they have nothing to show, and copyable
* technical values.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { InventoryDeviceModal } from '../InventoryDeviceModal'
import type { InventoryEntry } from '@/types'
const mockUpdatePending = vi.fn()
vi.mock('@/api/client', () => ({
scanApi: { updatePending: (...a: unknown[]) => mockUpdatePending(...a) },
}))
vi.mock('sonner', async () => (await import('@/test/mocks')).mockSonner())
function makeDevice(overrides: Partial<InventoryEntry> = {}): InventoryEntry {
return {
id: 'dev-1',
ip: '192.168.1.100',
mac: 'aa:bb:cc:dd:ee:ff',
hostname: 'pve.local',
os: 'Linux',
services: [],
suggested_type: 'server',
status: 'pending',
discovered_at: '2024-01-15T10:30:00Z',
...overrides,
}
}
const noop = { onClose: vi.fn(), onApprove: vi.fn(), onHide: vi.fn(), onIgnore: vi.fn() }
beforeEach(() => {
vi.clearAllMocks()
mockUpdatePending.mockResolvedValue({ data: makeDevice() })
})
describe('InventoryDeviceModal — header', () => {
it('badges every source that observed the device', () => {
render(
<InventoryDeviceModal
{...noop}
// No `ip`, so the only "IP" on screen is the source badge itself.
device={makeDevice({ ip: null, discovery_source: 'arp', discovery_sources: ['arp', 'proxmox'] })}
/>
)
expect(screen.getByText('IP')).toBeInTheDocument()
expect(screen.getByText('PROXMOX')).toBeInTheDocument()
})
it('shows live reachability, which is not the pending/approved lifecycle', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ status: 'pending', status_live: 'online' })} />)
expect(screen.getByText('Online')).toBeInTheDocument()
})
it('reads unknown when the device has never been checked', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
expect(screen.getByText('Unknown')).toBeInTheDocument()
})
it('counts the canvases drawing the device', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ canvas_count: 3 })} />)
expect(screen.getByTitle('Drawn on 3 canvases')).toBeInTheDocument()
})
it('marks a hidden row', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ status: 'hidden' })} />)
expect(screen.getByText('Hidden')).toBeInTheDocument()
})
})
describe('InventoryDeviceModal — view sections', () => {
// What answers on a host belongs beside how to reach it, not in the curation
// column next to the properties.
it('puts the services under Identity', () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({ services: [{ port: 22, protocol: 'tcp', service_name: 'SSH' }] })}
/>
)
const identity = screen.getByTestId('device-column-identity')
expect(identity).toHaveTextContent('Identity')
expect(identity).toHaveTextContent('Services found (1)')
expect(screen.getByTestId('device-column-curation')).not.toHaveTextContent('Services found')
})
it('heads every section with an icon', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
const headings = [...screen.getByRole('dialog').querySelectorAll('section > header > span:first-child')]
expect(headings.length).toBeGreaterThan(0)
for (const heading of headings) {
expect(heading.querySelector('svg')).not.toBeNull()
}
})
it('keeps a section with an empty-state rather than dropping it', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
expect(screen.getByText('No properties.')).toBeInTheDocument()
expect(screen.getByText('No notes.')).toBeInTheDocument()
expect(screen.getByText('Not monitored — pick a check method from Edit.')).toBeInTheDocument()
})
// Hardware is curated as properties, like every other spec — the modal has no
// hardware section on either side.
it('shows no hardware section — specs live in the properties', () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({ cpu_count: 4, ram_gb: 8, cpu_model: 'N5105', disk_gb: 500 })}
/>
)
expect(screen.queryByText('Hardware')).toBeNull()
expect(screen.queryByText('4 vCPU · N5105 · 8 GB RAM · 500 GB disk')).toBeNull()
})
it('offers no hardware inputs in the form', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ ram_gb: 8, show_hardware: true })} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.queryByText('Hardware')).toBeNull()
expect(screen.queryByText('RAM (GB)')).toBeNull()
expect(screen.queryByText('Show hardware specs on the node')).toBeNull()
})
it('suggests the spec keys so a hardware value is one click from a property', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByTitle('Add a RAM property')).toBeInTheDocument()
expect(screen.getByTitle('Add a CPU Model property')).toBeInTheDocument()
})
// Proxmox and the YAML import still write these columns; an edit here must not
// wipe what they recorded.
it('sends the stored hardware values back untouched', async () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({ cpu_count: 4, cpu_model: 'N5105', ram_gb: 8, disk_gb: 500, show_hardware: true })}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
const body = mockUpdatePending.mock.calls[0][1]
expect(body).toMatchObject({
cpu_count: 4,
cpu_model: 'N5105',
ram_gb: 8,
disk_gb: 500,
show_hardware: true,
})
})
it('shows the response time of the last check', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice({ check_method: 'ping', response_time_ms: 12 })} />)
expect(screen.getByText('12 ms')).toBeInTheDocument()
})
it('copies a technical value to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.assign(navigator, { clipboard: { writeText } })
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByLabelText('Copy IP'))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('192.168.1.100'))
})
it('offers no copy button on a value that is not an address', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
expect(screen.queryByLabelText('Copy OS')).toBeNull()
})
})
describe('InventoryDeviceModal — edit sections', () => {
it('edits the make-and-model fields the view shows', async () => {
render(
<InventoryDeviceModal
{...noop}
device={makeDevice({ vendor: 'Dell', friendly_name: 'kitchen', device_subtype: 'router' })}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByDisplayValue('kitchen'), { target: { value: 'garage' } })
fireEvent.change(screen.getByDisplayValue('router'), { target: { value: 'switch' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(mockUpdatePending).toHaveBeenCalled())
const body = mockUpdatePending.mock.calls[0][1]
expect(body.friendly_name).toBe('garage')
expect(body.device_subtype).toBe('switch')
expect(body.vendor).toBe('Dell')
})
it('drops the edits on Cancel rather than keeping them staged', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByDisplayValue('pve.local'), { target: { value: 'renamed.local' } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByDisplayValue('pve.local')).toBeInTheDocument()
expect(screen.queryByDisplayValue('renamed.local')).toBeNull()
})
it('hides the lifecycle actions while editing', () => {
render(<InventoryDeviceModal {...noop} device={makeDevice()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.queryByRole('button', { name: 'Hide' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Delete' })).toBeNull()
// …and offers no second Edit while the form is open.
expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull()
})
})
@@ -288,7 +288,7 @@ describe('NodeModal', () => {
// ── Container mode ─────────────────────────────────────────────────────
const containerModeTypes = ['proxmox', 'vm', 'lxc', 'docker_host'] as const
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
const nonContainerModeTypes = ['isp', 'router', 'switch', 'server', 'nas', 'kvm', 'ap', 'printer', 'iot', 'camera', 'cpl', 'computer', 'generic', 'docker_container', 'groupRect', 'group'] as const
it.each(containerModeTypes)('shows Container Mode toggle for %s type', (type) => {
renderModal({ initial: { ...BASE, type } })
@@ -424,6 +424,42 @@ describe('NodeModal', () => {
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBeUndefined()
})
it('keeps a group parent_id on submit for a plain node', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'g1' },
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
})
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('g1')
})
it('keeps a group parent_id when the type changes', () => {
const { onSubmit } = renderModal({
initial: { ...BASE, type: 'server', parent_id: 'g1' },
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
})
fireEvent.change(selects()[0], { target: { value: 'nas' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
expect((onSubmit.mock.calls[0][0] as Partial<NodeData>).parent_id).toBe('g1')
})
it('offers a group as a parent option for a plain node', () => {
renderModal({
initial: { ...BASE, type: 'server' },
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
})
const option = screen.getByRole('option', { name: 'My Group' }) as HTMLOptionElement
expect(option.value).toBe('g1')
})
it('does not offer a group as a parent for another group', () => {
renderModal({
initial: { ...BASE, type: 'group' },
parentCandidates: [{ id: 'g1', label: 'My Group', type: 'group' }],
})
expect(screen.queryByText('Parent Container')).toBeNull()
})
// ── Appearance ────────────────────────────────────────────────────────
it('renders 3 color swatch labels (border, background, icon)', () => {
@@ -507,15 +543,29 @@ describe('NodeModal', () => {
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)', () => {
it('disables the − button at 0, the minimum for every side', () => {
renderModal({ initial: BASE })
expect((screen.getByRole('button', { name: 'Decrease Left connection points' }) as HTMLButtonElement).disabled).toBe(true)
// Top starts at its default of 1, so it can still go down to 0.
expect((screen.getByRole('button', { name: 'Decrease Top connection points' }) as HTMLButtonElement).disabled).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Decrease Top connection points' }))
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', () => {
it('takes top/bottom down to 0 connection points', () => {
const { onSubmit } = renderModal({ initial: BASE })
fireEvent.change(screen.getByLabelText('Top connection points'), { target: { value: '0' } })
fireEvent.change(screen.getByLabelText('Bottom connection points'), { target: { value: '0' } })
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
const submitted = onSubmit.mock.calls[0][0] as Partial<NodeData>
expect(submitted.top_handles).toBe(0)
expect(submitted.bottom_handles).toBe(0)
})
it('min is 0 on every side; max is 64 everywhere', () => {
renderModal({ initial: BASE })
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('1')
expect((screen.getByLabelText('Bottom connection points') as HTMLInputElement).min).toBe('0')
expect((screen.getByLabelText('Top connection points') as HTMLInputElement).min).toBe('0')
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')
@@ -28,6 +28,7 @@ describe('ServiceModal', () => {
protocol: 'udp',
port: 80,
path: '/admin',
host: undefined,
icon: undefined,
})
expect(onClose).toHaveBeenCalledOnce()
@@ -117,6 +118,34 @@ describe('ServiceModal', () => {
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ path: undefined }))
})
it('prefills and clears the host override', () => {
const withHost = serviceToForm({ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' })
const { onSubmit } = setup({ initial: withHost, confirmLabel: 'Save' })
const input = screen.getByPlaceholderText('Node host (app.example.com)') as HTMLInputElement
expect(input.value).toBe('blog.example.com')
fireEvent.change(input, { target: { value: '' } })
submit('Save')
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined }))
})
})
describe('host override', () => {
it('submits a trimmed host override', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' blog.example.com ' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: 'blog.example.com' }))
})
it('submits undefined when the host is left blank', () => {
const { onSubmit } = setup()
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: ' ' } })
submit()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ host: undefined }))
})
})
describe('icon picker', () => {
+18 -3
View File
@@ -1,5 +1,5 @@
import { createElement, useRef, useState } from 'react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical } from 'lucide-react'
import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff, GripVertical, Boxes } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -16,12 +16,15 @@ import type { Node } from '@xyflow/react'
interface DetailPanelProps {
onEdit: (id: string) => void
/** Opens the Device Inventory on this node's row. Absent in contexts with no
* inventory (standalone, the tour), where the link is simply not offered. */
onOpenInventory?: (deviceId: string) => void
}
/** Open service editor: `index === null` means "add a new service". */
type SvcModalState = { nodeId: string; index: number | null; form?: ServiceFormData }
export function DetailPanel({ onEdit }: DetailPanelProps) {
export function DetailPanel({ onEdit, onOpenInventory }: DetailPanelProps) {
const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore()
const serviceStatuses = useCanvasStore((s) => s.serviceStatuses)
@@ -102,6 +105,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
protocol: data.protocol,
service_name: data.service_name,
...(data.path ? { path: data.path } : {}),
...(data.host ? { host: data.host } : {}),
...(data.icon ? { icon: data.icon } : {}),
}
updateNode(node.id, { services: [...services, svc] })
@@ -115,6 +119,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
service_name: data.service_name,
port: data.port,
path: data.path,
host: data.host,
icon: data.icon,
}
: svc
@@ -207,6 +212,16 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
{data.last_scan && <DetailRow label="Last Scan" value={formatTimestamp(data.last_scan)} />}
{data.created_at && <DetailRow label="Created" value={formatTimestamp(data.created_at)} />}
{data.updated_at && <DetailRow label="Last Modified" value={formatTimestamp(data.updated_at)} />}
{/* The row behind this node — where the same facts live for every other
canvas showing it. */}
{data.device_id && onOpenInventory && (
<button
onClick={() => onOpenInventory(data.device_id as string)}
className="mt-1 flex items-center gap-1 text-[10px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors cursor-pointer"
>
<Boxes size={10} /> Open in inventory
</button>
)}
</div>
{/* Size section — manual width/height entry for pixel-exact sizing */}
@@ -233,7 +248,7 @@ export function DetailPanel({ onEdit }: DetailPanelProps) {
key={`${svc.port ?? 'host'}-${svc.protocol}-${svc.path ?? ''}-${i}`}
svc={svc}
host={host}
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol)]}
status={serviceStatuses[serviceStatusKey(node.id, svc.port, svc.protocol, svc.host)]}
draggable={services.length > 1}
isDragging={dragSvcIndex === i}
isDragOver={dragOverSvcIndex === i && dragSvcIndex !== i}
+19 -8
View File
@@ -388,14 +388,25 @@ function VersionBadge() {
return (
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
<a
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
v{current}
</a>
<div className="flex items-center gap-1.5">
<a
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
v{current}
</a>
<span className="text-[11px] text-muted-foreground">-</span>
<a
href="https://homelable.net/changelog/"
target="_blank"
rel="noopener noreferrer"
className="text-[11px] text-[#00d4ff] hover:text-[#00d4ff]/80 transition-colors"
>
Changelog
</a>
</div>
{hasUpdate && latest && (
<a
href={latest.url.startsWith('https://') ? latest.url : '#'}
@@ -137,9 +137,9 @@ describe('DetailPanel', () => {
fireEvent.click(addButtons[0]) // first Add = properties
// Form is now open — fill key and value
fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } })
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } })
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'RTX 4090' } })
// The PropertyForm confirm button is labeled "Add" — use the form's confirm button
fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' })
fireEvent.keyDown(screen.getByPlaceholderText(/^Value/), { key: 'Enter' })
expect(updateNode).toHaveBeenCalledOnce()
const [, payload] = updateNode.mock.calls[0]
expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true })
@@ -243,7 +243,7 @@ describe('DetailPanel', () => {
const addButtons = screen.getAllByText('Add')
fireEvent.click(addButtons[0])
// Only fill value, leave key empty
fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } })
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: 'some value' } })
const confirmButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(confirmButtons[confirmButtons.length - 1])
expect(updateNode).not.toHaveBeenCalled()
@@ -445,6 +445,46 @@ describe('DetailPanel', () => {
expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined()
})
it('stores a host override on the added service and links it', () => {
const updateNode = vi.fn()
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10' })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode,
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
const addHeaders = screen.getAllByText('Add')
fireEvent.click(addHeaders[addHeaders.length - 1])
fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'blog' } })
fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '443' } })
fireEvent.change(screen.getByPlaceholderText('Node host (app.example.com)'), { target: { value: 'blog.example.com' } })
fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement)
expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'blog', host: 'blog.example.com' })
})
it('links a service badge to its host override rather than the node IP', () => {
vi.mocked(canvasStore.useCanvasStore).mockReturnValue({
nodes: [makeNode({ ip: '192.168.1.10', services: [{ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com' }] })],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
} as unknown as ReturnType<typeof canvasStore.useCanvasStore>)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'blog' }).getAttribute('href')).toBe('https://blog.example.com:443')
})
it('calls updateNode without the removed service when X is clicked', () => {
const updateNode = vi.fn()
const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' }
@@ -744,7 +784,7 @@ describe('DetailPanel', () => {
it('paints a service red when its live status is offline', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'offline' },
{ 'n1:8080/tcp@': 'offline' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
@@ -753,11 +793,27 @@ describe('DetailPanel', () => {
it('keeps the category colour when the live status is online', () => {
setupStore(
{ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] },
{ 'n1:8080/tcp': 'online' },
{ 'n1:8080/tcp@': 'online' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
})
it('paints only the offline vhost red when two services share a port', () => {
setupStore(
{
ip: '192.168.1.10',
services: [
{ port: 443, protocol: 'tcp', service_name: 'blog', host: 'blog.example.com', path: '' },
{ port: 443, protocol: 'tcp', service_name: 'shop', host: 'shop.example.com', path: '' },
],
},
{ 'n1:443/tcp@shop.example.com': 'offline' },
)
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.getByRole('link', { name: 'blog' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web)
expect(screen.getByRole('link', { name: 'shop' }).style.color).toBe('rgb(248, 81, 73)') // #f85149
})
})
describe('Last Seen formatting', () => {
@@ -0,0 +1,69 @@
/**
* The panel offers a way through to the Device Inventory row behind the node —
* the place the same facts live for every other canvas showing this device.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { DetailPanel } from '../DetailPanel'
import * as canvasStore from '@/stores/canvasStore'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
vi.mock('@/stores/canvasStore', async (importActual) => ({
...(await importActual<typeof canvasStore>()),
useCanvasStore: vi.fn(),
}))
function makeNode(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: 'server',
position: { x: 0, y: 0 },
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
}
}
function setupStore(nodeData: Partial<NodeData> = {}) {
const state = {
nodes: [makeNode(nodeData)],
selectedNodeId: 'n1',
selectedNodeIds: [],
setSelectedNode: vi.fn(),
deleteNode: vi.fn(),
updateNode: vi.fn(),
snapshotHistory: vi.fn(),
createGroup: vi.fn(),
ungroup: vi.fn(),
removeFromGroup: vi.fn(),
setNodeSize: vi.fn(),
serviceStatuses: {},
}
vi.mocked(canvasStore.useCanvasStore).mockImplementation(
((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore,
)
}
beforeEach(() => vi.clearAllMocks())
describe('DetailPanel — inventory link', () => {
it('opens the inventory on the linked device', () => {
setupStore({ device_id: 'dev-1' })
const onOpenInventory = vi.fn()
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={onOpenInventory} />)
fireEvent.click(screen.getByRole('button', { name: /Open in inventory/i }))
expect(onOpenInventory).toHaveBeenCalledWith('dev-1')
})
it('offers nothing for a node with no row yet', () => {
setupStore({})
render(<DetailPanel onEdit={vi.fn()} onOpenInventory={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
})
it('offers nothing where there is no inventory to open (standalone)', () => {
setupStore({ device_id: 'dev-1' })
render(<DetailPanel onEdit={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Open in inventory/i })).toBeNull()
})
})
@@ -83,6 +83,13 @@ describe('VersionBadge', () => {
expect(link).toHaveAttribute('target', '_blank')
})
it('links to the public changelog next to the version', () => {
renderSidebar()
const link = screen.getByText('Changelog').closest('a')
expect(link).toHaveAttribute('href', 'https://homelable.net/changelog/')
expect(link).toHaveAttribute('target', '_blank')
})
it('does not show update badge when on latest version', () => {
renderSidebar()
expect(screen.queryByText(/available/)).not.toBeInTheDocument()
@@ -0,0 +1,148 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types'
import { useAlignmentGuides } from '../useAlignmentGuides'
const nodesRef: { current: Node<NodeData>[] } = { current: [] }
const setNodes = vi.fn()
vi.mock('@xyflow/react', async () => (await import('@/test/mocks')).mockReactFlow({
useReactFlow: () => ({
getNodes: () => nodesRef.current,
setNodes,
}),
}))
function makeFlowNode(
id: string,
x: number,
y: number,
over: Partial<Node<NodeData>> = {},
): Node<NodeData> {
return {
id,
type: 'custom',
position: { x, y },
data: { label: id, type: 'server' } as NodeData,
measured: { width: 100, height: 50 },
...over,
}
}
const evt = {} as MouseEvent
describe('useAlignmentGuides — parented nodes', () => {
beforeEach(() => {
localStorage.clear()
setNodes.mockClear()
nodesRef.current = []
})
it('emits guides for a node dragged inside a group (parent-relative coords)', () => {
// Group at (500, 500); two children in parent-relative coordinates.
// Child "b" sits 3px off "a" horizontally → within the default threshold.
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('a', 20, 20, { parentId: 'group' }),
makeFlowNode('b', 23, 200, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
})
// Guides are in absolute canvas space: group.x + a.x = 520.
const xGuides = result.current.guides.filter((g) => g.axis === 'x')
expect(xGuides.length).toBeGreaterThan(0)
expect(xGuides.map((g) => g.position)).toContain(520)
})
it('snaps the dragged child on drag stop, keeping its parent-relative position', () => {
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('a', 20, 20, { parentId: 'group' }),
makeFlowNode('b', 23, 200, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
result.current.onNodeDragStop(evt, nodesRef.current[2], [nodesRef.current[2]])
})
expect(setNodes).toHaveBeenCalledTimes(1)
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
// -3 applied to the relative position: 23 → 20, absolute 523 → 520.
expect(updated.find((n) => n.id === 'b')!.position.x).toBe(20)
expect(updated.find((n) => n.id === 'a')!.position.x).toBe(20)
})
it('aligns a child against a top-level node outside its group', () => {
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('outside', 520, 50),
makeFlowNode('child', 24, 100, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[2], [nodesRef.current[2]])
})
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 520)).toBe(true)
})
it('resolves nested container chains (container in a group)', () => {
nodesRef.current = [
makeFlowNode('group', 100, 100, { type: 'group', measured: { width: 600, height: 600 } }),
makeFlowNode('host', 50, 50, { parentId: 'group', measured: { width: 300, height: 300 } }),
makeFlowNode('vm1', 10, 10, { parentId: 'host' }),
makeFlowNode('vm2', 14, 120, { parentId: 'host' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[3], [nodesRef.current[3]])
})
// vm1 absolute x = 100 + 50 + 10 = 160.
expect(result.current.guides.some((g) => g.axis === 'x' && g.position === 160)).toBe(true)
})
it('does not snap a child whose parent is dragged too (no double shift)', () => {
nodesRef.current = [
makeFlowNode('group', 497, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('child', 23, 20, { parentId: 'group' }),
makeFlowNode('other', 500, 1200),
]
const { result } = renderHook(() => useAlignmentGuides())
const dragged = [nodesRef.current[0], nodesRef.current[1]]
act(() => {
result.current.onNodeDrag(evt, dragged[0], dragged)
result.current.onNodeDragStop(evt, dragged[0], dragged)
})
const updated = setNodes.mock.calls[0][0](nodesRef.current) as Node<NodeData>[]
// The group snaps by +3; the child follows it, so its relative x is untouched.
expect(updated.find((n) => n.id === 'group')!.position.x).toBe(500)
expect(updated.find((n) => n.id === 'child')!.position.x).toBe(23)
})
it('ignores nodes that follow the drag as alignment candidates', () => {
// Only the group is dragged; its child must not act as a candidate for itself.
nodesRef.current = [
makeFlowNode('group', 500, 500, { type: 'group', measured: { width: 400, height: 400 } }),
makeFlowNode('child', 2, 20, { parentId: 'group' }),
]
const { result } = renderHook(() => useAlignmentGuides())
act(() => {
result.current.onNodeDrag(evt, nodesRef.current[0], [nodesRef.current[0]])
})
expect(result.current.guides).toHaveLength(0)
})
})
@@ -113,22 +113,25 @@ describe('useStatusPolling', () => {
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ token: 'test-token' }))
})
it('calls setNodeStatus with correct data on status message', () => {
it('lights up every node drawing the checked device', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
device_id: 'dev-1',
node_ids: ['node-1', 'node-2'],
status: 'online',
checked_at: '2024-01-01T12:00:00Z',
response_time_ms: 42,
}),
})
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', {
const expected = {
status: 'online',
response_time_ms: 42,
last_seen: '2024-01-01T12:00:00Z',
})
}
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-1', expected)
expect(mockSetNodeStatus).toHaveBeenCalledWith('node-2', expected)
})
it('sets last_seen to undefined when status is offline', () => {
@@ -136,7 +139,8 @@ describe('useStatusPolling', () => {
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({
node_id: 'node-1',
device_id: 'dev-1',
node_ids: ['node-1'],
status: 'offline',
checked_at: '2024-01-01T12:00:00Z',
}),
@@ -152,7 +156,9 @@ describe('useStatusPolling', () => {
renderHook(() => useStatusPolling())
const ws = MockWebSocket.instances[0]
ws.onmessage?.({
data: JSON.stringify({ node_id: 'node-1', status: 'online', response_time_ms: null }),
data: JSON.stringify({
device_id: 'dev-1', node_ids: ['node-1'], status: 'online', response_time_ms: null,
}),
})
expect(mockSetNodeStatus).toHaveBeenCalledWith(
'node-1',
@@ -173,7 +179,9 @@ describe('useStatusPolling', () => {
const ws = MockWebSocket.instances[0]
const services = [{ port: 80, protocol: 'tcp', status: 'offline' }]
ws.onmessage?.({
data: JSON.stringify({ type: 'service_status', node_id: 'node-9', services }),
data: JSON.stringify({
type: 'service_status', device_id: 'dev-9', node_ids: ['node-9'], services,
}),
})
expect(mockSetServiceStatuses).toHaveBeenCalledWith('node-9', services)
expect(mockSetNodeStatus).not.toHaveBeenCalled()
+63 -15
View File
@@ -11,14 +11,54 @@ import type { NodeData } from '@/types'
type NodeDrag = OnNodeDrag<Node<NodeData>>
function nodeBox(n: Node<NodeData>): Box | null {
// Skip parented nodes — alignment between absolute and parent-relative
// coordinates is misleading. Top-level nodes only for v1.
if (n.parentId) return null
// Absolute canvas position of a node: React Flow stores a parented node's
// position relative to its parent, so we walk the parentId chain and sum.
// `seen` guards against a corrupted cycle in the hierarchy.
function absolutePosition(
n: Node<NodeData>,
byId: Map<string, Node<NodeData>>,
): { x: number; y: number } {
let x = n.position.x
let y = n.position.y
const seen = new Set<string>([n.id])
let parentId = n.parentId
while (parentId && !seen.has(parentId)) {
seen.add(parentId)
const parent = byId.get(parentId)
if (!parent) break
x += parent.position.x
y += parent.position.y
parentId = parent.parentId
}
return { x, y }
}
// Boxes are expressed in absolute canvas coordinates so a node inside a group
// or a container_mode node aligns against siblings *and* outside nodes.
function nodeBox(n: Node<NodeData>, byId: Map<string, Node<NodeData>>): Box | null {
const width = n.measured?.width ?? n.width ?? null
const height = n.measured?.height ?? n.height ?? null
if (width == null || height == null) return null
return { id: n.id, x: n.position.x, y: n.position.y, width, height }
const { x, y } = absolutePosition(n, byId)
return { id: n.id, x, y, width, height }
}
// True when any ancestor of `n` is itself being dragged — such a node follows
// its parent, so it must not be snapped again (double-shift) nor act as an
// alignment candidate (it moves with the drag).
function hasDraggedAncestor(
n: Node<NodeData>,
byId: Map<string, Node<NodeData>>,
draggedIds: Set<string>,
): boolean {
const seen = new Set<string>([n.id])
let parentId = n.parentId
while (parentId && !seen.has(parentId)) {
if (draggedIds.has(parentId)) return true
seen.add(parentId)
parentId = byId.get(parentId)?.parentId
}
return false
}
/**
@@ -74,27 +114,35 @@ export function useAlignmentGuides() {
return
}
const all = getNodes()
const byId = new Map(all.map((n) => [n.id, n]))
const draggedIds = new Set((dragNodes.length > 0 ? dragNodes : [dragNode]).map((n) => n.id))
// Restrict the snap to top-level dragged nodes. nodeBox returns null for
// parented nodes; if we kept their ids in pendingSnap, onNodeDragStop
// would shift their parent-relative position by the same delta the parent
// already moves by, double-snapping the child. Children follow the parent
// automatically; no extra shift needed.
// A dragged node whose parent is also dragged already moves with that
// parent; snapping it too would shift its parent-relative position by the
// same delta twice. Its box still joins the union bbox (it is part of what
// the user sees moving), it just never lands in pendingSnap.ids.
const draggedBoxEntries = all
.filter((n) => draggedIds.has(n.id))
.map((n) => ({ id: n.id, box: nodeBox(n) }))
.filter((e): e is { id: string; box: Box } => e.box !== null)
.map((n) => ({
id: n.id,
box: nodeBox(n, byId),
follows: hasDraggedAncestor(n, byId, draggedIds),
}))
.filter((e): e is { id: string; box: Box; follows: boolean } => e.box !== null)
if (draggedBoxEntries.length === 0) {
clearState()
return
}
const snapIds = new Set(draggedBoxEntries.map((e) => e.id))
const snapIds = new Set(draggedBoxEntries.filter((e) => !e.follows).map((e) => e.id))
if (snapIds.size === 0) {
clearState()
return
}
const boxes = draggedBoxEntries.map((e) => e.box)
const dragged = boxes.length === 1 ? boxes[0] : unionBox(boxes)
if (!dragged) return
const candidates = all
.filter((n) => !draggedIds.has(n.id))
.map(nodeBox)
.filter((n) => !draggedIds.has(n.id) && !hasDraggedAncestor(n, byId, draggedIds))
.map((n) => nodeBox(n, byId))
.filter((b): b is Box => b !== null)
const result = computeSnap(dragged, candidates, settings.threshold)
setGuides(result.guides)
+38 -9
View File
@@ -6,12 +6,19 @@ import type { ServiceStatus } from '@/types'
interface ServiceStatusEntry {
port?: number
protocol?: string
/** Per-service host override, part of the overlay key — several vhosts can
* share one port on one node. Null when the service has none. */
host?: string | null
status: ServiceStatus
}
interface StatusMessage {
type?: string
node_id?: string
/** The device that was checked. A device is checked once, however many
* canvases draw it. */
device_id?: string
/** The nodes drawing that device, as the backend knew them at check time. */
node_ids?: string[]
status?: 'online' | 'offline' | 'pending' | 'unknown'
checked_at?: string
response_time_ms?: number | null
@@ -22,6 +29,25 @@ interface StatusMessage {
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
/**
* The nodes a device-scoped message applies to.
*
* The backend sends the node ids it knew at check time; a node placed since
* then is resolved from the store by `device_id`, so a freshly approved device
* lights up on the first cycle rather than the second.
*/
function targets(msg: StatusMessage): string[] {
const known = msg.node_ids ?? []
if (!msg.device_id) return known
// `getState` is absent when the store is mocked; the message's own list is
// then the whole answer.
const nodes = useCanvasStore.getState?.()?.nodes ?? []
const fromStore = nodes
.filter((n) => n.data.device_id === msg.device_id)
.map((n) => n.id)
return [...new Set([...known, ...fromStore])]
}
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
@@ -51,16 +77,19 @@ export function useStatusPolling() {
const msg: StatusMessage = JSON.parse(event.data)
if (msg.type === 'scan_device_found') {
notifyScanDeviceFound()
} else if (msg.type === 'service_status' && msg.node_id && msg.services) {
setServiceStatuses(msg.node_id, msg.services)
} else if (msg.node_id && msg.status) {
} else if (msg.type === 'service_status' && msg.services) {
for (const nodeId of targets(msg)) setServiceStatuses(nodeId, msg.services)
} else if (msg.status) {
// Live status is monitoring data, not a user edit — must not dirty the
// canvas (otherwise autosave rewrites an untouched canvas every cycle).
setNodeStatus(msg.node_id, {
status: msg.status,
response_time_ms: msg.response_time_ms ?? undefined,
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
})
// One check lights up every node drawing that device.
for (const nodeId of targets(msg)) {
setNodeStatus(nodeId, {
status: msg.status,
response_time_ms: msg.response_time_ms ?? undefined,
last_seen: msg.status === 'online' ? msg.checked_at : undefined,
})
}
}
} catch {
// ignore malformed messages
@@ -161,6 +161,19 @@ describe('CableLayer selection', () => {
expect(lines).toEqual(['Uplink', 'Length: 2 m'])
})
it('prints a value-less property as a bare label', () => {
const store = useRackStore.getState()
store.setCableVisibility('always')
store.updateCable(store.cables[0].id, {
labelVisible: false,
properties: [{ key: 'Spare', value: '', icon: null, visible: true }],
})
const { getAllByTestId } = render(<CableLayer />)
const lines = Array.from(getAllByTestId('cable-annotation')[0].querySelectorAll('text')).map((t) => t.textContent)
expect(lines).toEqual(['Spare'])
})
it('draws the selected cable with a highlight halo', () => {
useRackStore.getState().toggleCableMode()
const { container, rerender } = render(<CableLayer />)
@@ -119,7 +119,7 @@ describe('RackCablePanel', () => {
render(<RackCablePanel />)
fireEvent.click(screen.getByText('+ Length'))
fireEvent.change(screen.getByPlaceholderText('Value (e.g. 2 m)'), { target: { value: '2 m' } })
fireEvent.change(screen.getByPlaceholderText(/^Value/), { target: { value: '2 m' } })
// Two buttons read "Add": the section's, then the form's confirm.
const addButtons = screen.getAllByRole('button', { name: 'Add' })
fireEvent.click(addButtons[addButtons.length - 1])
+2 -1
View File
@@ -47,7 +47,8 @@ function annotationLines(cable: Cable): string[] {
const lines: string[] = []
if (cable.labelVisible && cable.label?.trim()) lines.push(cable.label.trim())
for (const prop of cable.properties ?? []) {
if (prop.visible) lines.push(`${prop.key}: ${prop.value}`)
// The value is optional — a property may print as a bare label.
if (prop.visible) lines.push(prop.value?.trim() ? `${prop.key}: ${prop.value}` : prop.key)
}
return lines
}
@@ -146,7 +146,7 @@ export function RackCablePanel() {
onChange={(next) => updateCable(cable.id, { properties: next })}
visibleLabel="Show on canvas"
keyPlaceholder="Label (e.g. Length)"
valuePlaceholder="Value (e.g. 2 m)"
valuePlaceholder="Value — optional (e.g. 2 m)"
suggestions={CABLE_PROPERTY_SUGGESTIONS}
emptyHint="No properties — add a length, a VLAN, whatever this run needs."
/>
@@ -83,6 +83,47 @@ describe('canvasStore — containers & nesting', () => {
expect(node?.position.y).toBe(80)
})
it('updateNode setting parent_id on a group nests the node visually', () => {
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
const child = { ...makeNode('s1', { type: 'server' }), position: { x: 160, y: 180 } }
useCanvasStore.getState().addNode(group)
useCanvasStore.getState().addNode(child)
useCanvasStore.getState().updateNode('s1', { parent_id: 'g1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
expect(node?.parentId).toBe('g1')
expect(node?.extent).toBe('parent')
expect(node?.position).toEqual({ x: 60, y: 80 })
})
it('updateNode re-sending an unchanged group parent_id keeps the node in place', () => {
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
const child = {
...makeNode('s1', { type: 'server', parent_id: 'g1' }),
parentId: 'g1',
extent: 'parent' as const,
position: { x: 20, y: 30 },
}
useCanvasStore.setState({ nodes: [group, child] })
// What the node modal submits on an unrelated edit: label plus the parent it
// already had. It must not detach or re-offset the node (#332 follow-up).
useCanvasStore.getState().updateNode('s1', { label: 'renamed', parent_id: 'g1' })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
expect(node?.data.parent_id).toBe('g1')
expect(node?.parentId).toBe('g1')
expect(node?.extent).toBe('parent')
expect(node?.position).toEqual({ x: 20, y: 30 })
})
it('addNode keeps a node nested when its parent is a group', () => {
const group = { ...makeNode('g1', { type: 'group' }), position: { x: 100, y: 100 } }
useCanvasStore.setState({ nodes: [group] })
useCanvasStore.getState().addNode({ ...makeNode('s1', { type: 'server', parent_id: 'g1' }), position: { x: 160, y: 180 } })
const node = useCanvasStore.getState().nodes.find((n) => n.id === 's1')
expect(node?.parentId).toBe('g1')
expect(node?.extent).toBe('parent')
expect(node?.position).toEqual({ x: 60, y: 80 })
})
it('updateNode setting parent_id on non-container proxmox does NOT set React Flow parentId', () => {
const proxmox = { ...makeNode('px1', { type: 'proxmox', container_mode: false }), position: { x: 100, y: 100 } }
const lxc = { ...makeNode('lxc1', { type: 'lxc' }), position: { x: 160, y: 180 } }
@@ -0,0 +1,98 @@
/**
* The device-fact baseline: what the canvas was given for each node, so a save
* can report its own edits instead of pushing a stale snapshot over an edit made
* meanwhile in the Device Inventory.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { useCanvasStore } from '@/stores/canvasStore'
import { changedFactFields } from '@/utils/deviceFacts'
import { makeNode, makeNodeData } from '@/test/factories'
function resetStore() {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
editSeq: 0,
selectedNodeId: null,
selectedNodeIds: [],
past: [],
future: [],
factsBaseline: {},
})
}
const loadedNode = (id = 'n1', deviceId = 'd-1') =>
makeNode(id, makeNodeData({ device_id: deviceId, label: 'NAS', ip: '10.0.0.5', notes: 'in the garage' }))
describe('canvasStore — device fact baseline', () => {
beforeEach(resetStore)
it('loadCanvas records what the server sent', () => {
const node = loadedNode()
useCanvasStore.getState().loadCanvas([node], [])
const { factsBaseline } = useCanvasStore.getState()
expect(changedFactFields(node.data, factsBaseline.n1)).toEqual([])
})
it('an edited node reports only what it edited', () => {
useCanvasStore.getState().loadCanvas([loadedNode()], [])
useCanvasStore.getState().updateNode('n1', { notes: 'moved to the loft' })
const { nodes, factsBaseline } = useCanvasStore.getState()
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual(['notes'])
})
it('markSaved rebases, so the next save claims nothing', () => {
useCanvasStore.getState().loadCanvas([loadedNode()], [])
useCanvasStore.getState().updateNode('n1', { notes: 'moved to the loft' })
useCanvasStore.getState().markSaved()
const { nodes, factsBaseline } = useCanvasStore.getState()
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual([])
})
})
describe('canvasStore — applyDeviceFacts', () => {
beforeEach(resetStore)
it('spreads an inventory edit onto every node drawing that device', () => {
useCanvasStore.getState().loadCanvas([loadedNode('n1'), loadedNode('n2')], [])
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
expect(useCanvasStore.getState().nodes.map((n) => n.data.notes)).toEqual([
'moved to the loft',
'moved to the loft',
])
})
it('leaves a node drawing another device alone', () => {
useCanvasStore.getState().loadCanvas([loadedNode('n1', 'd-1'), loadedNode('n2', 'd-2')], [])
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
expect(useCanvasStore.getState().nodes[1].data.notes).toBe('in the garage')
})
it('rebases what it applied, so the canvas does not claim the row edit as its own', () => {
useCanvasStore.getState().loadCanvas([loadedNode()], [])
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
const { nodes, factsBaseline } = useCanvasStore.getState()
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual([])
})
it('is not a canvas edit — the row already holds it', () => {
useCanvasStore.getState().loadCanvas([loadedNode()], [])
useCanvasStore.getState().applyDeviceFacts('d-1', { notes: 'moved to the loft' })
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false)
expect(useCanvasStore.getState().editSeq).toBe(0)
})
it('keeps a pending canvas edit when the same device changes elsewhere', () => {
useCanvasStore.getState().loadCanvas([loadedNode()], [])
useCanvasStore.getState().updateNode('n1', { label: 'Big NAS' })
// The row carries its own (older) label alongside the edited note.
useCanvasStore.getState().applyDeviceFacts('d-1', { label: 'NAS', notes: 'moved to the loft' })
const { nodes, factsBaseline } = useCanvasStore.getState()
// Unsaved work in progress is not overwritten by the row…
expect(nodes[0].data.label).toBe('Big NAS')
expect(nodes[0].data.notes).toBe('moved to the loft')
// …and is still this canvas' to save. The note is not.
expect(changedFactFields(nodes[0].data, factsBaseline.n1)).toEqual(['label'])
})
})
@@ -34,8 +34,25 @@ describe('canvasStore — nodes', () => {
{ port: 443, protocol: 'tcp', status: 'online' },
])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('offline')
expect(serviceStatuses['node-1:443/tcp']).toBe('online')
expect(serviceStatuses['node-1:80/tcp@']).toBe('offline')
expect(serviceStatuses['node-1:443/tcp@']).toBe('online')
})
it('setServiceStatuses keys vhosts sharing a port apart by host', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [
{ port: 443, protocol: 'tcp', host: 'blog.example.com', status: 'online' },
{ port: 443, protocol: 'tcp', host: 'shop.example.com', status: 'offline' },
])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:443/tcp@blog.example.com']).toBe('online')
expect(serviceStatuses['node-1:443/tcp@shop.example.com']).toBe('offline')
})
it('setServiceStatuses treats a null host from the wire as no override', () => {
const { setServiceStatuses } = useCanvasStore.getState()
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', host: null, status: 'online' }])
expect(useCanvasStore.getState().serviceStatuses['node-1:80/tcp@']).toBe('online')
})
it('setServiceStatuses merges without dropping other nodes', () => {
@@ -43,8 +60,8 @@ describe('canvasStore — nodes', () => {
setServiceStatuses('node-1', [{ port: 80, protocol: 'tcp', status: 'online' }])
setServiceStatuses('node-2', [{ port: 22, protocol: 'tcp', status: 'offline' }])
const { serviceStatuses } = useCanvasStore.getState()
expect(serviceStatuses['node-1:80/tcp']).toBe('online')
expect(serviceStatuses['node-2:22/tcp']).toBe('offline')
expect(serviceStatuses['node-1:80/tcp@']).toBe('online')
expect(serviceStatuses['node-2:22/tcp@']).toBe('offline')
})
it('does not mark canvas unsaved on a service status update', () => {
@@ -0,0 +1,135 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { useCanvasStore } from '@/stores/canvasStore'
import { makeNode } from '@/test/factories'
function resetStore() {
useCanvasStore.setState({
nodes: [],
edges: [],
hasUnsavedChanges: false,
selectedNodeId: null,
selectedNodeIds: [],
editingGroupRectId: null,
editingTextId: null,
past: [],
future: [],
clipboard: { nodes: [], edges: [] },
serviceStatuses: {},
floorMap: null,
})
}
const zone = (id: string, x = 100, y = 100) => ({
...makeNode(id, { type: 'groupRect' }),
type: 'groupRect',
position: { x, y },
width: 400,
height: 300,
})
describe('canvasStore — zone (groupRect) parenting', () => {
beforeEach(resetStore)
it('addToZone parents the node and rebases its position on the zone', () => {
useCanvasStore.setState({
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
})
useCanvasStore.getState().addToZone('z1', 'n1')
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(child.parentId).toBe('z1')
expect(child.data.parent_id).toBe('z1')
expect(child.position).toEqual({ x: 60, y: 120 })
})
it('addToZone does not clamp the child with extent, so it can be dragged out', () => {
useCanvasStore.setState({ nodes: [zone('z1'), makeNode('n1')] })
useCanvasStore.getState().addToZone('z1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.extent).toBeUndefined()
})
it('addToZone orders the zone before its child (React Flow requirement)', () => {
useCanvasStore.setState({ nodes: [makeNode('n1'), zone('z1')] })
useCanvasStore.getState().addToZone('z1', 'n1')
const ids = useCanvasStore.getState().nodes.map((n) => n.id)
expect(ids.indexOf('z1')).toBeLessThan(ids.indexOf('n1'))
})
it('addToZone is a no-op when the target is not a zone', () => {
useCanvasStore.setState({ nodes: [makeNode('g1', { type: 'group' }), makeNode('n1')] })
useCanvasStore.getState().addToZone('g1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')?.parentId).toBeUndefined()
})
it('addToZone is a no-op when the node is already in that zone', () => {
useCanvasStore.setState({ nodes: [zone('z1'), makeNode('n1')] })
useCanvasStore.getState().addToZone('z1', 'n1')
const first = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
useCanvasStore.getState().addToZone('z1', 'n1')
expect(useCanvasStore.getState().nodes.find((n) => n.id === 'n1')).toEqual(first)
})
it('removeFromGroup releases a zone child back to absolute coords', () => {
useCanvasStore.setState({
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
})
useCanvasStore.getState().addToZone('z1', 'n1')
useCanvasStore.getState().removeFromGroup('z1', 'n1')
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(child.parentId).toBeUndefined()
expect(child.data.parent_id).toBeUndefined()
expect(child.position).toEqual({ x: 160, y: 220 })
})
it('deleting a zone releases its children instead of deleting them', () => {
useCanvasStore.setState({
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
})
useCanvasStore.getState().addToZone('z1', 'n1')
useCanvasStore.getState().deleteNode('z1')
const nodes = useCanvasStore.getState().nodes
expect(nodes.map((n) => n.id)).toEqual(['n1'])
expect(nodes[0].parentId).toBeUndefined()
expect(nodes[0].position).toEqual({ x: 160, y: 220 })
})
it('deleting a container still cascades to its children', () => {
useCanvasStore.setState({
nodes: [
makeNode('px1', { type: 'proxmox', container_mode: true }),
{ ...makeNode('vm1'), parentId: 'px1' },
],
})
useCanvasStore.getState().deleteNode('px1')
expect(useCanvasStore.getState().nodes).toHaveLength(0)
})
it('addNode nests a node under a zone without clamping it', () => {
useCanvasStore.getState().addNode(zone('z1'))
useCanvasStore.getState().addNode({
...makeNode('n1', { parent_id: 'z1' }),
position: { x: 160, y: 220 },
})
const child = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(child.parentId).toBe('z1')
expect(child.extent).toBeUndefined()
expect(child.position).toEqual({ x: 60, y: 120 })
})
it('updateNode can attach a node to a zone and detach it again', () => {
useCanvasStore.setState({
nodes: [zone('z1'), { ...makeNode('n1'), position: { x: 160, y: 220 } }],
})
useCanvasStore.getState().updateNode('n1', { parent_id: 'z1' })
const attached = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(attached.parentId).toBe('z1')
expect(attached.extent).toBeUndefined()
useCanvasStore.getState().updateNode('n1', { parent_id: undefined })
const detached = useCanvasStore.getState().nodes.find((n) => n.id === 'n1')!
expect(detached.parentId).toBeUndefined()
expect(detached.position).toEqual({ x: 160, y: 220 })
})
})
+147 -14
View File
@@ -15,6 +15,7 @@ import { normalizeHandle, removedHandleIds, handleCountField, sideDefault, handl
import { applyOpacity } from '@/utils/colorUtils'
import { readHideIp, writeHideIp } from '@/utils/ipDisplay'
import { CONTAINER_MODE_TYPES } from '@/utils/virtualEdgeParent'
import { changedFactFields, factsBaselineOf, factsBaselines, type FactsBaseline } from '@/utils/deviceFacts'
type HistoryEntry = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
type Clipboard = { nodes: Node<NodeData>[]; edges: Edge<EdgeData>[] }
@@ -101,9 +102,11 @@ function translateWaypointsForMovedNodes(
})
}
/** Key for the live per-service status overlay. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}`
/** Key for the live per-service status overlay.
* `host` is part of the key because several vhosts can share one port on one
* node — without it they'd all read the same status. */
export const serviceStatusKey = (nodeId: string, port?: number, protocol?: string, host?: string | null): string =>
`${nodeId}:${port ?? ''}/${protocol ?? ''}@${host?.trim() ?? ''}`
interface CanvasState {
nodes: Node<NodeData>[]
@@ -120,6 +123,19 @@ interface CanvasState {
selectedNodeId: string | null
selectedNodeIds: string[]
scanEventTs: number
/**
* The device facts as this canvas received them, per node id. A save diffs
* against it to tell the backend what *this* canvas edited, so a save made for
* nothing but a moved node cannot push a stale snapshot over an edit made
* meanwhile in the Device Inventory. Refreshed on load and on save.
*/
factsBaseline: Record<string, FactsBaseline>
/**
* Apply an inventory row's facts to every node drawing that device, and rebase
* them. Not an edit — the row is already persisted — so it leaves
* hasUnsavedChanges alone.
*/
applyDeviceFacts: (deviceId: string, facts: Partial<NodeData>) => void
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus>
@@ -175,6 +191,7 @@ interface CanvasState {
ungroup: (groupId: string) => void
addToGroup: (groupId: string, childId: string) => void
addToContainer: (containerId: string, childId: string) => void
addToZone: (zoneId: string, childId: string) => void
removeFromGroup: (groupId: string, childId: string) => void
markSaved: () => void
markUnsaved: () => void
@@ -186,7 +203,7 @@ interface CanvasState {
fitViewPending: boolean
clearFitViewPending: () => void
notifyScanDeviceFound: () => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; status: ServiceStatus }[]) => void
setServiceStatuses: (nodeId: string, statuses: { port?: number; protocol?: string; host?: string | null; status: ServiceStatus }[]) => void
hideIp: boolean
toggleHideIp: () => void
setHideIp: (value: boolean) => void
@@ -228,6 +245,7 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
editingTextId: null,
hideIp: readHideIp(),
scanEventTs: 0,
factsBaseline: {},
serviceStatuses: {},
floorMap: null,
floorMapEditNonce: 0,
@@ -431,12 +449,16 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
addNode: (node) =>
set((state) => {
const parent = node.data.parent_id ? state.nodes.find((n) => n.id === node.data.parent_id) : null
const shouldNestInParent = !!(parent?.data.container_mode)
// A visual group — and a groupRect zone — nests its children just like a
// container-mode host.
const shouldNestInParent = !!(parent?.data.container_mode) || parent?.data.type === 'group' || parent?.data.type === 'groupRect'
// Zones keep their children free to be dragged back out (see addToZone).
const nestExtent = parent?.data.type === 'groupRect' ? undefined : ('parent' as const)
const enriched = node.data.parent_id && shouldNestInParent
? {
...node,
parentId: node.data.parent_id,
extent: 'parent' as const,
extent: nestExtent,
position: {
x: Math.max(10, node.position.x - parent.position.x),
y: Math.max(10, node.position.y - parent.position.y),
@@ -485,10 +507,10 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
updated.extent = undefined
} else if (newParentId && newParentId !== n.parentId) {
const parent = state.nodes.find((p) => p.id === newParentId)
if (parent?.data.container_mode) {
// Attaching to a container-mode Proxmox: nest visually
if (parent?.data.container_mode || parent?.data.type === 'group' || parent?.data.type === 'groupRect') {
// Attaching to a container-mode host, a visual group or a zone.
updated.parentId = newParentId
updated.extent = 'parent' as const
updated.extent = parent.data.type === 'groupRect' ? undefined : ('parent' as const)
// Convert absolute position to parent-relative (keep node visible inside)
updated.position = {
x: Math.max(10, n.position.x - parent.position.x),
@@ -545,13 +567,34 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
deleteNode: (id) =>
set((state) => {
const idsToRemove = new Set<string>()
// Deleting a zone deletes the zone, never what it happens to contain:
// its children are released back to the canvas in absolute coords.
const released: Node<NodeData>[] = []
const collect = (nodeId: string) => {
idsToRemove.add(nodeId)
state.nodes.filter((n) => n.parentId === nodeId).forEach((n) => collect(n.id))
const node = state.nodes.find((n) => n.id === nodeId)
const children = state.nodes.filter((n) => n.parentId === nodeId)
if (node?.data.type === 'groupRect') {
children.forEach((c) => released.push({
...c,
parentId: undefined,
extent: undefined,
position: {
x: node.position.x + c.position.x,
y: node.position.y + c.position.y,
},
data: { ...c.data, parent_id: undefined },
}))
return
}
children.forEach((n) => collect(n.id))
}
collect(id)
const releasedById = new Map(released.map((n) => [n.id, n]))
return {
nodes: state.nodes.filter((n) => !idsToRemove.has(n.id)),
nodes: state.nodes
.filter((n) => !idsToRemove.has(n.id))
.map((n) => releasedById.get(n.id) ?? n),
edges: state.edges.filter((e) => !idsToRemove.has(e.source) && !idsToRemove.has(e.target)),
selectedNodeId: idsToRemove.has(state.selectedNodeId ?? '') ? null : state.selectedNodeId,
hasUnsavedChanges: true,
@@ -873,6 +916,53 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
}
}),
// Nest an existing top-level node inside a groupRect zone. Mirrors
// addToGroup, with one deliberate difference: NO `extent: 'parent'`. A zone
// is a loose visual area, so a child must stay draggable out of it — the
// canvas detaches it on drop (see CanvasContainer). Parenting is what makes
// moving the zone move its contents.
addToZone: (zoneId, childId) =>
set((state) => {
const zone = state.nodes.find((n) => n.id === zoneId)
const child = state.nodes.find((n) => n.id === childId)
if (!zone || !child || zone.data.type !== 'groupRect') return state
if (child.id === zoneId || child.parentId === zoneId) return state
// A zone cannot become a child of a node it already contains.
if (zone.parentId === childId) return state
const updatedNodes = state.nodes.map((n) => {
if (n.id !== childId) return n
return {
...n,
parentId: zoneId,
// Absolute → zone-relative.
position: {
x: n.position.x - zone.position.x,
y: n.position.y - zone.position.y,
},
selected: false,
data: { ...n.data, parent_id: zoneId },
}
})
// React Flow requires the parent to precede its children in the array.
const others = updatedNodes.filter((n) => n.id !== childId)
const movedChild = updatedNodes.find((n) => n.id === childId)!
const zoneIdx = others.findIndex((n) => n.id === zoneId)
const nodes = [
...others.slice(0, zoneIdx + 1),
movedChild,
...others.slice(zoneIdx + 1),
]
return {
nodes,
hasUnsavedChanges: true,
past: [...state.past.slice(-49), { nodes: state.nodes, edges: state.edges }],
future: [],
}
}),
// Release a single child from a group back to the canvas. Group stays.
removeFromGroup: (groupId, childId) =>
set((state) => {
@@ -902,7 +992,40 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
}
}),
markSaved: () => set({ hasUnsavedChanges: false }),
// The save just became the server's truth, so it is the new baseline: the next
// save reports only what is edited from here on.
markSaved: () => set((state) => ({ hasUnsavedChanges: false, factsBaseline: factsBaselines(state.nodes) })),
applyDeviceFacts: (deviceId, facts) =>
set((state) => {
let changed = false
const factsBaseline = { ...state.factsBaseline }
const nodes = state.nodes.map((n) => {
if (n.data.device_id !== deviceId) return n
const previous = state.factsBaseline[n.id]
// A fact this canvas has already edited but not saved is the user's
// work in progress — the row does not get to overwrite it on screen.
const pending = new Set<string>(changedFactFields(n.data, previous))
const applied = Object.fromEntries(
Object.entries(facts).filter(([field]) => !pending.has(field)),
)
if (Object.keys(applied).length === 0) return n
changed = true
const data = { ...n.data, ...applied }
// Rebase only what was applied: an untouched pending edit stays reported
// as this canvas' change so the next save still writes it.
const rebased = factsBaselineOf(data)
for (const field of pending) {
if (previous?.[field] !== undefined) rebased[field] = previous[field]
}
factsBaseline[n.id] = rebased
return { ...n, data }
})
// No hasUnsavedChanges: the row already holds this, the canvas is catching
// up. Rebasing alongside is what keeps the next save from claiming the
// inventory's own edit as a canvas edit.
return changed ? { nodes, factsBaseline } : {}
}),
markUnsaved: () => set({ hasUnsavedChanges: true }),
@@ -913,7 +1036,7 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
// Live overlay only — never touches node data, so it stays out of saves.
const next = { ...state.serviceStatuses }
for (const s of statuses) {
next[serviceStatusKey(nodeId, s.port, s.protocol)] = s.status
next[serviceStatusKey(nodeId, s.port, s.protocol, s.host)] = s.status
}
return { serviceStatuses: next }
}),
@@ -945,7 +1068,17 @@ export const useCanvasStore = create<CanvasState>((rawSet) => {
const children = nodes.filter((n) => !!n.parentId)
// NOTE: clipboard is intentionally preserved here so nodes copied in one
// design can be pasted after switching to another design.
set({ nodes: [...parents, ...children], edges, hasUnsavedChanges: false, selectedNodeId: null, past: [], future: [], fitViewPending: true })
set({
nodes: [...parents, ...children],
edges,
hasUnsavedChanges: false,
selectedNodeId: null,
past: [],
future: [],
fitViewPending: true,
// What the server just gave us: the reference a save diffs against.
factsBaseline: factsBaselines(nodes),
})
},
applyLayout: (nodes, edges) =>
+2 -2
View File
@@ -65,6 +65,6 @@ export function makeUseCanvasStore<S extends Record<string, unknown>>(state: S)
}
/** Common `serviceStatusKey` helper mirrored from the real store. */
export function serviceStatusKey(nodeId: string, port?: number, protocol?: string) {
return `${nodeId}:${port ?? ''}/${protocol ?? ''}`
export function serviceStatusKey(nodeId: string, port?: number, protocol?: string, host?: string) {
return `${nodeId}:${port ?? ''}/${protocol ?? ''}@${host?.trim() ?? ''}`
}
+76
View File
@@ -33,6 +33,7 @@ export type NodeType =
| 'vm'
| 'lxc'
| 'nas'
| 'kvm'
| 'iot'
| 'ap'
| 'camera'
@@ -92,6 +93,10 @@ export interface ServiceInfo {
path?: string
icon?: string
category?: string
/** Overrides the node host when building the service URL — one node can serve
* several domains. Same accepted shapes as a node `ip`/`hostname`
* (`host`, `host:port`, `https://host/…`). */
host?: string
}
export type ServiceStatus = 'online' | 'offline' | 'unknown'
@@ -103,9 +108,79 @@ export interface NodeProperty {
visible: boolean
}
/**
* A Device Inventory row, as `/scan/pending` returns it.
*
* The inventory row — not the canvas node — owns what a device *is*: its
* addresses, services, properties, notes and hardware. A node describes how
* that device is drawn on one canvas. Re-exported from
* `components/modals/InventoryDeviceModal` for the call sites that predate this
* home.
*/
export interface InventoryEntry {
id: string
ip: string | null
mac: string | null
hostname: string | null
os: string | null
services: ServiceInfo[]
suggested_type: string | null
/** Inventory lifecycle: pending / approved / hidden. Not reachability. */
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
properties?: NodeProperty[]
discovered_at: string
/** Curated facts, editable from the device detail modal. */
label?: string | null
type?: string | null
notes?: string | null
cpu_count?: number | null
cpu_model?: string | null
ram_gb?: number | null
disk_gb?: number | null
show_hardware?: boolean
check_method?: CheckMethod | null
check_target?: string | null
/** Live reachability from the status checker — distinct from `status`. */
status_live?: string
last_seen?: string | null
last_scan?: string | null
response_time_ms?: number | null
updated_at?: string | null
/** How many canvases (designs) this device already appears on. Server-computed. */
canvas_count?: number
/**
* Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
* Null/absent when the device is not on any canvas yet.
*/
node_created_at?: string | null
node_last_scan?: string | null
node_last_modified?: string | null
node_last_seen?: string | null
}
export interface NodeData extends Record<string, unknown> {
label: string
type: NodeType
/**
* The Device Inventory row this node draws. The row owns the device facts
* below (ip, services, properties, notes…) — they arrive hydrated from it and
* a save routes them back to it. Null/absent for canvas furniture
* (group / groupRect / text), which describes nothing physical.
*/
device_id?: string | null
hostname?: string
ip?: string
mac?: string
@@ -213,6 +288,7 @@ export const NODE_TYPE_LABELS: Record<NodeType, string> = {
vm: 'Virtual Machine',
lxc: 'LXC Container',
nas: 'NAS',
kvm: 'KVM Switch',
iot: 'IoT Device',
ap: 'Access Point',
camera: 'Camera',
@@ -0,0 +1,68 @@
/**
* A node carries the id of the Device Inventory row it draws. The link has to
* survive a save, or the next one would mint a second row for the same device.
*/
import { describe, it, expect } from 'vitest'
import { serializeNode, deserializeApiNode } from '@/utils/canvasSerializer'
import { DEVICE_FACT_FIELDS, factsBaselineOf } from '@/utils/deviceFacts'
import type { NodeData } from '@/types'
import type { Node } from '@xyflow/react'
function node(data: Partial<NodeData>): Node<NodeData> {
return {
id: 'n1',
type: data.type ?? 'server',
position: { x: 10, y: 20 },
data: { label: 'NAS', type: 'server', status: 'online', services: [], ...data },
}
}
describe('serializeNode — device link', () => {
it('sends the device_id it was loaded with', () => {
expect(serializeNode(node({ device_id: 'dev-1' })).device_id).toBe('dev-1')
})
it('sends null when the node has no row yet', () => {
expect(serializeNode(node({})).device_id).toBeNull()
})
it('sends null for a zone — furniture describes nothing physical', () => {
expect(serializeNode(node({ type: 'groupRect' })).device_id).toBeNull()
})
})
describe('serializeNode — changed_facts', () => {
it('claims nothing when the node still holds what it loaded', () => {
const n = node({ device_id: 'dev-1', notes: 'in the garage' })
expect(serializeNode(n, factsBaselineOf(n.data)).changed_facts).toEqual([])
})
it('claims only the edited fact, so the rest of the row is left alone', () => {
const loaded = node({ device_id: 'dev-1', notes: 'in the garage' })
const edited = node({ device_id: 'dev-1', notes: 'moved to the loft' })
expect(serializeNode(edited, factsBaselineOf(loaded.data)).changed_facts).toEqual(['notes'])
})
it('claims every fact with no baseline — a node the server has never seen', () => {
expect(serializeNode(node({ device_id: null })).changed_facts).toEqual([...DEVICE_FACT_FIELDS])
})
})
describe('deserializeApiNode — device link', () => {
it('hoists device_id onto node data', () => {
const rf = deserializeApiNode(
{
id: 'n1',
type: 'server',
label: 'NAS',
pos_x: 0,
pos_y: 0,
status: 'online',
services: [],
device_id: 'dev-1',
},
new Map(),
)
expect(rf.data.device_id).toBe('dev-1')
})
})
@@ -366,6 +366,12 @@ describe('deserializeApiNode — regular node', () => {
expect(result.extent).toBe('parent')
})
it('restores a zone child parented but not extent-clamped', () => {
const result = deserializeApiNode(makeApiNode({ parent_id: 'z1' }), emptyMap, new Set(['z1']))
expect(result.parentId).toBe('z1')
expect(result.extent).toBeUndefined()
})
it('does not set parentId when parent is not in container mode', () => {
const map = new Map([['px1', false]])
const result = deserializeApiNode(makeApiNode({ parent_id: 'px1' }), map)
@@ -4,6 +4,7 @@ import {
getVisibleNodeIds,
rewireEdgesForCollapse,
getZoneSpatialChildren,
getZoneChildren,
computeCollapseInfo,
} from '../collapseFilter'
import type { EdgeData, NodeData } from '@/types'
@@ -134,6 +135,31 @@ describe('getZoneSpatialChildren', () => {
})
})
describe('getZoneChildren', () => {
it('unions real parentId children with the ones merely sitting on the zone', () => {
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
const parented = mkNode('parented', { parentId: 'zone', type: 'server' })
const overlapping = mkNode('overlapping', { position: { x: 100, y: 50 }, type: 'server' })
const outside = mkNode('outside', { position: FAR, type: 'server' })
const found = getZoneChildren(zone, [zone, parented, overlapping, outside])
expect(found.sort()).toEqual(['overlapping', 'parented'])
})
it('does not double-count a child that is both parented and overlapping', () => {
const zone = mkNode('zone', { position: { x: 0, y: 0 }, width: 400, height: 300 })
const child = mkNode('child', { parentId: 'zone', position: { x: 10, y: 10 }, type: 'server' })
expect(getZoneChildren(zone, [zone, child])).toEqual(['child'])
})
})
describe('computeCollapseInfo — a zone with real children', () => {
it('hides parentId children when the zone collapses', () => {
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
const child = mkNode('child', { parentId: 'zone', type: 'server' })
expect(getVisibleNodeIds([zone, child])).toEqual(new Set(['zone']))
})
})
describe('computeCollapseInfo — spatial collapse via groupRect zones', () => {
it('hides nodes spatially inside a collapsed zone and records hiddenBy', () => {
const zone = mkNode('zone', { collapsed: true, width: 400, height: 300 })
@@ -0,0 +1,99 @@
/**
* A node carries a copy of its inventory row's facts, hydrated on load. Telling
* "this canvas edited it" apart from "the row moved on since" needs the values
* the canvas was given — that baseline is what these functions keep.
*/
import { describe, it, expect } from 'vitest'
import {
DEVICE_FACT_FIELDS,
changedFactFields,
deviceFactsToNodeData,
factsBaselineOf,
factsBaselines,
} from '@/utils/deviceFacts'
import type { InventoryEntry, NodeData } from '@/types'
import type { Node } from '@xyflow/react'
const data = (over: Partial<NodeData> = {}): NodeData => ({
label: 'NAS',
type: 'nas',
status: 'online',
services: [],
ip: '10.0.0.5',
notes: 'in the garage',
...over,
})
describe('changedFactFields', () => {
it('reports nothing when the node still holds what it was given', () => {
const base = factsBaselineOf(data())
expect(changedFactFields(data(), base)).toEqual([])
})
it('reports only the edited field', () => {
const base = factsBaselineOf(data())
expect(changedFactFields(data({ notes: 'moved to the loft' }), base)).toEqual(['notes'])
})
it('ignores live status — the checker owns reachability, not a canvas', () => {
const base = factsBaselineOf(data())
expect(changedFactFields(data({ status: 'offline' }), base)).toEqual([])
expect(DEVICE_FACT_FIELDS).not.toContain('status')
})
it('compares lists by value, not identity', () => {
const props = [{ key: 'Rack', value: 'A1', icon: null, visible: true }]
const base = factsBaselineOf(data({ properties: props }))
expect(changedFactFields(data({ properties: [...props] }), base)).toEqual([])
expect(changedFactFields(data({ properties: [] }), base)).toEqual(['properties'])
})
it('treats every fact as changed with no baseline — a new node has nothing to revert', () => {
expect(changedFactFields(data(), undefined)).toEqual([...DEVICE_FACT_FIELDS])
})
})
describe('factsBaselines', () => {
it('keys one baseline per node', () => {
const nodes = [
{ id: 'n1', position: { x: 0, y: 0 }, data: data() },
{ id: 'n2', position: { x: 0, y: 0 }, data: data({ notes: 'rack B' }) },
] as Node<NodeData>[]
const baselines = factsBaselines(nodes)
expect(Object.keys(baselines)).toEqual(['n1', 'n2'])
expect(changedFactFields(nodes[1].data, baselines.n2)).toEqual([])
expect(changedFactFields(nodes[1].data, baselines.n1)).toEqual(['notes'])
})
})
describe('deviceFactsToNodeData', () => {
const row = (over: Partial<InventoryEntry> = {}): InventoryEntry => ({
id: 'd-1',
ip: '10.0.0.5',
mac: null,
hostname: 'nas.lan',
os: null,
services: [],
suggested_type: null,
status: 'approved',
discovered_at: '2026-01-01T00:00:00Z',
...over,
})
it('carries the curated facts a node draws', () => {
const out = deviceFactsToNodeData(row({ label: 'Big NAS', type: 'nas', notes: 'loft' }))
expect(out.label).toBe('Big NAS')
expect(out.type).toBe('nas')
expect(out.notes).toBe('loft')
expect(out.ip).toBe('10.0.0.5')
})
it('leaves the inventory lifecycle out — it is not node data', () => {
expect(deviceFactsToNodeData(row()).status).toBeUndefined()
})
it('falls back through the same name precedence the inventory shows', () => {
expect(deviceFactsToNodeData(row({ friendly_name: 'Nas box' })).label).toBe('Nas box')
expect(deviceFactsToNodeData(row()).label).toBe('nas.lan')
})
})
@@ -3,6 +3,8 @@ import {
MIN_BOTTOM_HANDLES,
MAX_BOTTOM_HANDLES,
MAX_HANDLES,
MIN_HANDLES,
sideHandleCount,
bottomHandleId,
bottomHandlePositions,
clampBottomHandles,
@@ -36,6 +38,7 @@ describe('clampBottomHandles', () => {
it('clamps below MIN to MIN', () => {
expect(clampBottomHandles(0)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(-5)).toBe(MIN_BOTTOM_HANDLES)
expect(MIN_BOTTOM_HANDLES).toBe(0)
})
it('supports at least 52 ports (issue #20 — Cisco 48+4 SFP)', () => {
@@ -47,11 +50,11 @@ describe('clampBottomHandles', () => {
expect(clampBottomHandles(9999)).toBe(MAX_BOTTOM_HANDLES)
})
it('returns MIN for non-finite or non-number', () => {
expect(clampBottomHandles(NaN)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(Infinity)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles('4' as unknown)).toBe(MIN_BOTTOM_HANDLES)
expect(clampBottomHandles(undefined)).toBe(MIN_BOTTOM_HANDLES)
it('returns the side default for non-finite or non-number', () => {
expect(clampBottomHandles(NaN)).toBe(1)
expect(clampBottomHandles(Infinity)).toBe(1)
expect(clampBottomHandles('4' as unknown)).toBe(1)
expect(clampBottomHandles(undefined)).toBe(1)
})
it('floors fractional values', () => {
@@ -113,7 +116,8 @@ describe('bottomHandlePositions — uniform spacing for ≥5', () => {
})
it('clamps out-of-range counts before computing', () => {
expect(bottomHandlePositions(0)).toEqual([50])
expect(bottomHandlePositions(0)).toEqual([])
expect(bottomHandlePositions(-2)).toEqual([])
expect(bottomHandlePositions(99)).toHaveLength(MAX_BOTTOM_HANDLES)
})
})
@@ -226,9 +230,11 @@ describe('handleId', () => {
})
describe('clampHandles', () => {
it('min is the side default (0 for L/R, 1 for T/B)', () => {
expect(clampHandles('top', 0)).toBe(1)
expect(clampHandles('bottom', -3)).toBe(1)
it('min is 0 for every side, top/bottom included', () => {
expect(clampHandles('top', 0)).toBe(0)
expect(clampHandles('bottom', 0)).toBe(0)
expect(clampHandles('top', -3)).toBe(0)
expect(clampHandles('bottom', -3)).toBe(0)
expect(clampHandles('left', -3)).toBe(0)
expect(clampHandles('right', 0)).toBe(0)
})
@@ -236,9 +242,10 @@ describe('clampHandles', () => {
expect(clampHandles('top', 9999)).toBe(MAX_HANDLES)
expect(clampHandles('left', 65)).toBe(64)
})
it('non-finite / non-number falls back to side min', () => {
it('non-finite / non-number falls back to the side default', () => {
expect(clampHandles('left', NaN)).toBe(0)
expect(clampHandles('top', undefined)).toBe(1)
expect(clampHandles('bottom', NaN)).toBe(1)
expect(clampHandles('right', '4' as unknown)).toBe(0)
})
it('floors fractional values', () => {
@@ -259,9 +266,9 @@ describe('handlePositions', () => {
expect(handlePositions('left', 0)).toEqual([])
expect(handlePositions('right', 0)).toEqual([])
})
it('top/bottom clamp 0 up to 1 (never empty)', () => {
expect(handlePositions('top', 0)).toEqual([50])
expect(handlePositions('bottom', 0)).toEqual([50])
it('top/bottom accept 0 and render nothing', () => {
expect(handlePositions('top', 0)).toEqual([])
expect(handlePositions('bottom', 0)).toEqual([])
})
})
@@ -307,3 +314,30 @@ describe('SIDES', () => {
expect([...SIDES].sort()).toEqual(['bottom', 'left', 'right', 'top'])
})
})
describe('zero connection points on top/bottom (issue: min was 1)', () => {
it('MIN_HANDLES is 0', () => {
expect(MIN_HANDLES).toBe(0)
})
it('sideHandleCount honours an explicit 0 on top and bottom', () => {
const data = { label: 'n', top_handles: 0, bottom_handles: 0 } as never
expect(sideHandleCount(data, 'top')).toBe(0)
expect(sideHandleCount(data, 'bottom')).toBe(0)
})
it('still defaults top/bottom to 1 when the field is missing', () => {
const data = { label: 'n' } as never
expect(sideHandleCount(data, 'top')).toBe(1)
expect(sideHandleCount(data, 'bottom')).toBe(1)
})
it('renders no handle position for a 0-count top/bottom side', () => {
expect(handlePositions('top', 0)).toEqual([])
expect(handlePositions('bottom', 0)).toEqual([])
})
it('removes every top handle when going 2 → 0', () => {
expect(removedHandleIds('top', 2, 0)).toEqual(new Set(['top', 'top-2']))
})
})
@@ -65,12 +65,43 @@ describe('parseYamlToCanvas', () => {
expect(d.cpu_count).toBe(16)
expect(d.ram_gb).toBe(64)
expect(d.disk_gb).toBe(2000)
expect(d.show_hardware).toBe(true)
})
it('sets show_hardware only when hardware fields present', () => {
// A node draws its `properties`; the hardware fields are inventory data. An
// import that set `show_hardware` alone showed the specs until the first
// save, then lost them — so the specs arrive as properties instead.
it('mints the hardware specs as visible properties', () => {
const yaml = `
- nodeType: proxmox
label: "PVE1"
cpuModel: "Intel Xeon"
cpuCore: 16
ram: 64
disk: 2000
`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes[0].data.properties).toEqual([
{ key: 'CPU Model', value: 'Intel Xeon', icon: 'Cpu', visible: true },
{ key: 'CPU Cores', value: '16', icon: 'Cpu', visible: true },
{ key: 'RAM', value: '64 GB', icon: 'MemoryStick', visible: true },
{ key: 'Disk', value: '2000 GB', icon: 'HardDrive', visible: true },
])
// The structured fields survive for the export round trip.
expect(nodes[0].data.cpu_count).toBe(16)
})
it('mints only the specs the YAML carries', () => {
const yaml = `- nodeType: server\n label: "RamOnly"\n ram: 8\n`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes[0].data.properties).toEqual([
{ key: 'RAM', value: '8 GB', icon: 'MemoryStick', visible: true },
])
})
it('leaves a node with no hardware without properties', () => {
const yaml = `- nodeType: server\n label: "NoHW"\n`
const { nodes } = parseYamlToCanvas(yaml, empty, emptyEdges)
expect(nodes[0].data.properties).toBeUndefined()
expect(nodes[0].data.show_hardware).toBeUndefined()
})
@@ -3,21 +3,23 @@ import { serviceToForm, EMPTY_SERVICE_FORM } from '../serviceForm'
describe('serviceToForm', () => {
it('maps a service onto string form fields', () => {
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', icon: 'database' })).toEqual({
expect(serviceToForm({ port: 443, protocol: 'udp', service_name: 'svc', path: '/x', host: 'svc.example.com', icon: 'database' })).toEqual({
service_name: 'svc',
port: '443',
protocol: 'udp',
path: '/x',
host: 'svc.example.com',
icon: 'database',
})
})
it('blanks out absent port and path', () => {
it('blanks out absent port, path and host', () => {
expect(serviceToForm({ protocol: 'tcp', service_name: 'svc' })).toEqual({
service_name: 'svc',
port: '',
protocol: 'tcp',
path: '',
host: '',
icon: undefined,
})
})
@@ -27,6 +29,6 @@ describe('serviceToForm', () => {
})
it('starts from a blank tcp form', () => {
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '' })
expect(EMPTY_SERVICE_FORM).toEqual({ service_name: '', port: '', protocol: 'tcp', path: '', host: '' })
})
})

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